controller_test.go 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804
  1. package sub
  2. import (
  3. "bytes"
  4. "encoding/base64"
  5. "fmt"
  6. "net/http"
  7. "net/http/httptest"
  8. "os"
  9. "path/filepath"
  10. "strings"
  11. "testing"
  12. "testing/fstest"
  13. "time"
  14. "github.com/gin-gonic/gin"
  15. "github.com/mhsanaei/3x-ui/v3/internal/database"
  16. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  17. "github.com/mhsanaei/3x-ui/v3/internal/web/service"
  18. )
  19. var testDistFS = fstest.MapFS{
  20. "dist/subpage.html": {Data: []byte(`<!doctype html><html><head></head><body><div id="root"></div></body></html>`)},
  21. }
  22. // newTestSUBController builds a controller with just the bits loadSubTemplate
  23. // needs, so the template tests don't require a database.
  24. func newTestSUBController() *SUBController {
  25. return &SUBController{subTemplateCache: map[string]*cachedSubTemplate{}}
  26. }
  27. type subscriptionTestRouterConfig struct {
  28. clashAutoDetect bool
  29. clashUserAgentRegex string
  30. jsonAutoDetect bool
  31. jsonUserAgentRegex string
  32. jsonAlwaysArray bool
  33. }
  34. func newSubscriptionTestRouter(config subscriptionTestRouterConfig) *gin.Engine {
  35. router := gin.New()
  36. options := []SUBControllerOption{
  37. WithSUBJsonEnabled(true),
  38. WithSUBClashEnabled(true),
  39. }
  40. if config.clashAutoDetect {
  41. options = append(options, WithSUBClashAutoDetect(true))
  42. }
  43. if config.clashUserAgentRegex != "" {
  44. options = append(options, WithSUBClashUserAgentRegex(config.clashUserAgentRegex))
  45. }
  46. if config.jsonAutoDetect {
  47. options = append(options, WithSUBJsonAutoDetect(true))
  48. }
  49. if config.jsonUserAgentRegex != "" {
  50. options = append(options, WithSUBJsonUserAgentRegex(config.jsonUserAgentRegex))
  51. }
  52. if config.jsonAlwaysArray {
  53. options = append(options, WithSUBJsonAlwaysArray(true))
  54. }
  55. NewSUBController(router.Group("/"), options...)
  56. return router
  57. }
  58. func TestNewSUBControllerOptions(t *testing.T) {
  59. gin.SetMode(gin.TestMode)
  60. defaults := NewSUBController(gin.New().Group("/"))
  61. if defaults.subPath != "/sub/" || defaults.subJsonPath != "/json/" || defaults.subClashPath != "/clash/" {
  62. t.Fatalf("default paths = %q, %q, %q", defaults.subPath, defaults.subJsonPath, defaults.subClashPath)
  63. }
  64. if !defaults.subEncrypt || defaults.updateInterval != "12" {
  65. t.Fatalf("default encryption/update = %v, %q", defaults.subEncrypt, defaults.updateInterval)
  66. }
  67. if defaults.subService.remarkTemplate != service.DefaultRemarkTemplate {
  68. t.Fatalf("default remark template = %q", defaults.subService.remarkTemplate)
  69. }
  70. if defaults.jsonEnabled || defaults.clashEnabled {
  71. t.Fatalf("format endpoints enabled by default: json=%v clash=%v", defaults.jsonEnabled, defaults.clashEnabled)
  72. }
  73. configured := NewSUBController(
  74. gin.New().Group("/"),
  75. WithSUBPath("/custom/"),
  76. WithSUBJsonEnabled(true),
  77. WithSUBEncryption(false),
  78. WithSUBUpdateInterval("24"),
  79. )
  80. if configured.subPath != "/custom/" || !configured.jsonEnabled || configured.subEncrypt || configured.updateInterval != "24" {
  81. t.Fatalf("configured values were not applied: path=%q json=%v encrypt=%v update=%q",
  82. configured.subPath, configured.jsonEnabled, configured.subEncrypt, configured.updateInterval)
  83. }
  84. }
  85. // A configured subscription path keeps its own format when it collides with a
  86. // hard-coded Clash alias, and the alias that does not collide still serves.
  87. func TestClashAliasesSkipConfiguredPathConflicts(t *testing.T) {
  88. seedSubDB(t)
  89. seedSubProtocolInbound(t, "s1", "vm", 4487, 1, `{"network":"tcp","security":"none"}`, model.VMESS)
  90. seedSubInbound(t, "s1", "vl", 4488, 2, `{"network":"tcp","security":"none"}`)
  91. gin.SetMode(gin.TestMode)
  92. type check struct {
  93. path string
  94. want []string
  95. notWant []string
  96. }
  97. // The full Mihomo profile is the only body carrying "type: vless"; the
  98. // legacy one keeps VMess and drops it.
  99. fullProfile := []string{"type: vmess", "type: vless"}
  100. legacyProfile := []string{"type: vmess"}
  101. tests := []struct {
  102. name string
  103. options []SUBControllerOption
  104. checks []check
  105. }{
  106. {
  107. name: "raw path uses Mihomo alias",
  108. options: []SUBControllerOption{WithSUBPath(subMihomoPath), WithSUBEncryption(false)},
  109. checks: []check{
  110. {path: "/mihomo/s1", want: []string{"vmess://"}, notWant: []string{"type: vmess"}},
  111. {path: "/clash-legacy/s1", want: legacyProfile, notWant: []string{"type: vless"}},
  112. },
  113. },
  114. {
  115. name: "JSON path uses legacy alias",
  116. options: []SUBControllerOption{WithSUBJsonEnabled(true), WithSUBJsonPath(subClashLegacyPath)},
  117. checks: []check{
  118. {path: "/clash-legacy/s1", want: []string{`"outbounds"`}, notWant: []string{"type: vmess"}},
  119. {path: "/mihomo/s1", want: fullProfile},
  120. },
  121. },
  122. {
  123. name: "configured Clash path is already Mihomo alias",
  124. options: []SUBControllerOption{WithSUBClashPath(subMihomoPath)},
  125. checks: []check{
  126. {path: "/mihomo/s1", want: fullProfile},
  127. {path: "/clash-legacy/s1", want: legacyProfile, notWant: []string{"type: vless"}},
  128. },
  129. },
  130. {
  131. name: "configured Clash path uses legacy alias",
  132. options: []SUBControllerOption{WithSUBClashPath(subClashLegacyPath)},
  133. checks: []check{
  134. {path: "/clash-legacy/s1", want: fullProfile},
  135. {path: "/mihomo/s1", want: fullProfile},
  136. },
  137. },
  138. }
  139. for _, tt := range tests {
  140. t.Run(tt.name, func(t *testing.T) {
  141. router := gin.New()
  142. NewSUBController(router.Group("/"), append([]SUBControllerOption{WithSUBClashEnabled(true)}, tt.options...)...)
  143. for _, c := range tt.checks {
  144. resp := httptest.NewRecorder()
  145. router.ServeHTTP(resp, httptest.NewRequest(http.MethodGet, "http://sub.example.com"+c.path, nil))
  146. if resp.Code != http.StatusOK {
  147. t.Fatalf("GET %s: status = %d, want 200; body=%s", c.path, resp.Code, resp.Body.String())
  148. }
  149. body := resp.Body.String()
  150. for _, want := range c.want {
  151. if !strings.Contains(body, want) {
  152. t.Fatalf("GET %s: body is missing %q:\n%s", c.path, want, body)
  153. }
  154. }
  155. for _, notWant := range c.notWant {
  156. if strings.Contains(body, notWant) {
  157. t.Fatalf("GET %s: body must not contain %q:\n%s", c.path, notWant, body)
  158. }
  159. }
  160. }
  161. })
  162. }
  163. }
  164. func TestShouldAutoServeClash(t *testing.T) {
  165. tests := []struct {
  166. name string
  167. autoDetect bool
  168. clashEnabled bool
  169. wantsHTML bool
  170. userAgent string
  171. pattern string
  172. want bool
  173. }{
  174. {name: "clash verge", autoDetect: true, clashEnabled: true, userAgent: "Clash-Verge/v2.4.2", want: true},
  175. {name: "mihomo", autoDetect: true, clashEnabled: true, userAgent: "mihomo/1.19.12", want: true},
  176. {name: "clash case insensitive", autoDetect: true, clashEnabled: true, userAgent: "CLASH-META/1.0", want: true},
  177. {name: "flclash covered by clash", autoDetect: true, clashEnabled: true, userAgent: "FlClash/0.8.91", want: true},
  178. {name: "clash for windows preserves existing detection", autoDetect: true, clashEnabled: true, userAgent: "ClashforWindows/0.20.39", want: true},
  179. {name: "generic client raw fallback", autoDetect: true, clashEnabled: true, userAgent: "GenericClient/1.10.0"},
  180. {name: "other client raw fallback", autoDetect: true, clashEnabled: true, userAgent: "OtherClient/2.2"},
  181. {name: "unknown raw fallback", autoDetect: true, clashEnabled: true, userAgent: "CustomClient/1.0"},
  182. {name: "empty raw fallback", autoDetect: true, clashEnabled: true},
  183. {name: "browser HTML wins", autoDetect: true, clashEnabled: true, wantsHTML: true, userAgent: "Clash-Verge/v2.4.2"},
  184. {name: "disabled by default", clashEnabled: true, userAgent: "mihomo/1.19.12"},
  185. {name: "clash endpoint disabled", autoDetect: true, userAgent: "mihomo/1.19.12"},
  186. }
  187. for _, tt := range tests {
  188. t.Run(tt.name, func(t *testing.T) {
  189. got := shouldAutoServeClash(tt.autoDetect, tt.clashEnabled, tt.wantsHTML, tt.userAgent, compileUserAgentRegex("Clash/Mihomo", tt.pattern, service.DefaultSubClashUserAgentRegex))
  190. if got != tt.want {
  191. t.Fatalf("shouldAutoServeClash() = %v, want %v", got, tt.want)
  192. }
  193. })
  194. }
  195. }
  196. func TestShouldAutoServeClashUsesConfiguredRegex(t *testing.T) {
  197. configured := compileUserAgentRegex("Clash/Mihomo", `(?i)^custom-client/`, service.DefaultSubClashUserAgentRegex)
  198. if !shouldAutoServeClash(true, true, false, "Custom-Client/1.0", configured) {
  199. t.Fatal("configured User-Agent regex did not match")
  200. }
  201. if shouldAutoServeClash(true, true, false, "Mihomo/1.19", configured) {
  202. t.Fatal("built-in User-Agent matched after a custom regex replaced it")
  203. }
  204. }
  205. func TestShouldAutoServeJson(t *testing.T) {
  206. configured := compileUserAgentRegex("Xray JSON", `(?i)^jsonclient([ /]|$)`, service.DefaultSubJsonUserAgentRegex)
  207. for _, userAgent := range []string{"JsonClient/1.6.32", "jsonclient 1.6.32"} {
  208. if !shouldAutoServeJson(true, true, false, userAgent, configured) {
  209. t.Errorf("configured Xray JSON regex did not match %q", userAgent)
  210. }
  211. }
  212. for _, userAgent := range []string{"GenericClient/1.10.0", "OtherClient/2.2", "ThirdClient/7.0", "CustomClient/1.0"} {
  213. if shouldAutoServeJson(true, true, false, userAgent, configured) {
  214. t.Errorf("configured Xray JSON regex unexpectedly matched %q", userAgent)
  215. }
  216. }
  217. if shouldAutoServeJson(false, true, false, "JsonClient/1.6.32", configured) {
  218. t.Fatal("disabled Xray JSON auto-detection matched")
  219. }
  220. if shouldAutoServeJson(true, false, false, "JsonClient/1.6.32", configured) {
  221. t.Fatal("disabled JSON endpoint matched")
  222. }
  223. if shouldAutoServeJson(true, true, true, "JsonClient/1.6.32", configured) {
  224. t.Fatal("browser HTML request matched Xray JSON")
  225. }
  226. empty := compileUserAgentRegex("Xray JSON", "", service.DefaultSubJsonUserAgentRegex)
  227. if empty != nil {
  228. t.Fatal("empty Xray JSON default should not compile to a matcher")
  229. }
  230. if shouldAutoServeJson(true, true, false, "JsonClient/1.6.32", empty) {
  231. t.Fatal("empty Xray JSON default should not auto-serve")
  232. }
  233. }
  234. func TestShouldAutoServeJsonUsesConfiguredRegex(t *testing.T) {
  235. configured := compileUserAgentRegex("Xray JSON", `(?i)^custom-json/`, service.DefaultSubJsonUserAgentRegex)
  236. if !shouldAutoServeJson(true, true, false, "Custom-JSON/1.0", configured) {
  237. t.Fatal("configured Xray JSON User-Agent regex did not match")
  238. }
  239. if shouldAutoServeJson(true, true, false, "OtherClient/1.10.0", configured) {
  240. t.Fatal("unrelated User-Agent matched after a custom regex was configured")
  241. }
  242. }
  243. func TestCompileUserAgentRegexFallsBackForInvalidPattern(t *testing.T) {
  244. compiled := compileUserAgentRegex("Clash/Mihomo", "[", service.DefaultSubClashUserAgentRegex)
  245. if !compiled.MatchString("Mihomo/1.19") {
  246. t.Fatal("invalid regex did not fall back to the default pattern")
  247. }
  248. }
  249. func TestSanitizeUserAgentForLog(t *testing.T) {
  250. if got := sanitizeUserAgentForLog("client/1.0\r\nforged\tline"); got != "client/1.0 forged line" {
  251. t.Fatalf("sanitizeUserAgentForLog() = %q", got)
  252. }
  253. long := strings.Repeat("界", 513)
  254. if got := sanitizeUserAgentForLog(long); len([]rune(got)) != 512 {
  255. t.Fatalf("sanitized User-Agent length = %d runes, want 512", len([]rune(got)))
  256. }
  257. }
  258. func seedSubMtprotoInbound(t *testing.T, subId, tag string, port int) {
  259. t.Helper()
  260. db := database.GetDB()
  261. secret := "ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d"
  262. email := tag + "@e"
  263. settings := fmt.Sprintf(`{"clients":[{"email":%q,"subId":%q,"enable":true,"secret":%q}]}`, email, subId, secret)
  264. ib := &model.Inbound{
  265. UserId: 1, Tag: tag, Enable: true, Listen: "203.0.113.5", Port: port,
  266. Protocol: model.MTProto, Remark: tag, Settings: settings, StreamSettings: "{}",
  267. }
  268. if err := db.Create(ib).Error; err != nil {
  269. t.Fatalf("seed mtproto inbound %s: %v", tag, err)
  270. }
  271. client := &model.ClientRecord{Email: email, SubID: subId, Secret: secret, Enable: true}
  272. if err := db.Create(client).Error; err != nil {
  273. t.Fatalf("seed client %s: %v", email, err)
  274. }
  275. if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: ib.Id}).Error; err != nil {
  276. t.Fatalf("seed client_inbound %s: %v", email, err)
  277. }
  278. }
  279. func TestAutoDetectFallsBackToRawWhenFormatHasNoContent(t *testing.T) {
  280. seedSubDB(t)
  281. seedSubMtprotoInbound(t, "s1", "tg", 4490)
  282. gin.SetMode(gin.TestMode)
  283. req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
  284. req.Header.Set("User-Agent", "Clash-Verge/v2.4.2")
  285. resp := httptest.NewRecorder()
  286. newSubscriptionTestRouter(subscriptionTestRouterConfig{clashAutoDetect: true, jsonAutoDetect: true}).ServeHTTP(resp, req)
  287. if resp.Code != http.StatusOK {
  288. t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
  289. }
  290. decoded, err := base64.StdEncoding.DecodeString(resp.Body.String())
  291. if err != nil {
  292. t.Fatalf("fallback response is not base64: %v", err)
  293. }
  294. if !strings.Contains(string(decoded), "tg://proxy") {
  295. t.Fatalf("decoded fallback lacks the Telegram proxy link: %s", decoded)
  296. }
  297. }
  298. func TestStandardSubscriptionAutoDetectsFormats(t *testing.T) {
  299. seedSubDB(t)
  300. seedSubInbound(t, "s1", "auto", 4480, 1, `{"network":"tcp","security":"none"}`)
  301. gin.SetMode(gin.TestMode)
  302. t.Run("recognized client receives YAML", func(t *testing.T) {
  303. req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
  304. req.Header.Set("User-Agent", "Clash-Verge/v2.4.2")
  305. resp := httptest.NewRecorder()
  306. newSubscriptionTestRouter(subscriptionTestRouterConfig{clashAutoDetect: true}).ServeHTTP(resp, req)
  307. if resp.Code != http.StatusOK {
  308. t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
  309. }
  310. if got := resp.Header().Get("Content-Type"); got != "application/yaml; charset=utf-8" {
  311. t.Fatalf("Content-Type = %q, want YAML", got)
  312. }
  313. if body := resp.Body.String(); !strings.Contains(body, "proxies:") || !strings.Contains(body, "type: vless") {
  314. t.Fatalf("auto-detected body is not Clash YAML:\n%s", body)
  315. }
  316. })
  317. t.Run("Clash wins when both format regexes match", func(t *testing.T) {
  318. req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
  319. req.Header.Set("User-Agent", "Hybrid/1.0")
  320. resp := httptest.NewRecorder()
  321. newSubscriptionTestRouter(subscriptionTestRouterConfig{
  322. clashAutoDetect: true,
  323. clashUserAgentRegex: `(?i)^hybrid/`,
  324. jsonAutoDetect: true,
  325. jsonUserAgentRegex: `(?i)^hybrid/`,
  326. }).ServeHTTP(resp, req)
  327. if resp.Code != http.StatusOK {
  328. t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
  329. }
  330. if got := resp.Header().Get("Content-Type"); got != "application/yaml; charset=utf-8" {
  331. t.Fatalf("Content-Type = %q, want Clash YAML", got)
  332. }
  333. })
  334. t.Run("disabled setting preserves raw base64", func(t *testing.T) {
  335. req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
  336. req.Header.Set("User-Agent", "Clash-Verge/v2.4.2")
  337. resp := httptest.NewRecorder()
  338. newSubscriptionTestRouter(subscriptionTestRouterConfig{}).ServeHTTP(resp, req)
  339. if resp.Code != http.StatusOK {
  340. t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
  341. }
  342. decoded, err := base64.StdEncoding.DecodeString(resp.Body.String())
  343. if err != nil {
  344. t.Fatalf("raw response is not base64: %v", err)
  345. }
  346. if !strings.Contains(string(decoded), "vless://") {
  347. t.Fatalf("decoded raw response lacks VLESS link: %s", decoded)
  348. }
  349. })
  350. t.Run("configured regex controls detection", func(t *testing.T) {
  351. req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
  352. req.Header.Set("User-Agent", "Mihomo/1.19")
  353. resp := httptest.NewRecorder()
  354. newSubscriptionTestRouter(subscriptionTestRouterConfig{
  355. clashAutoDetect: true,
  356. clashUserAgentRegex: `(?i)^custom-client/`,
  357. }).ServeHTTP(resp, req)
  358. if resp.Code != http.StatusOK {
  359. t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
  360. }
  361. if got := resp.Header().Get("Content-Type"); got == "application/yaml; charset=utf-8" {
  362. t.Fatalf("Content-Type = %q, custom regex should preserve raw response", got)
  363. }
  364. })
  365. t.Run("unrecognized client preserves raw base64", func(t *testing.T) {
  366. req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
  367. req.Header.Set("User-Agent", "GenericClient/1.10.0")
  368. resp := httptest.NewRecorder()
  369. newSubscriptionTestRouter(subscriptionTestRouterConfig{
  370. clashAutoDetect: true,
  371. jsonAutoDetect: true,
  372. }).ServeHTTP(resp, req)
  373. if resp.Code != http.StatusOK {
  374. t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
  375. }
  376. decoded, err := base64.StdEncoding.DecodeString(resp.Body.String())
  377. if err != nil {
  378. t.Fatalf("raw response is not base64: %v", err)
  379. }
  380. if !strings.Contains(string(decoded), "vless://") {
  381. t.Fatalf("decoded raw response lacks VLESS link: %s", decoded)
  382. }
  383. })
  384. t.Run("recognized Xray JSON client receives configuration array", func(t *testing.T) {
  385. req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
  386. req.Header.Set("User-Agent", "JsonClient/1.6.32")
  387. resp := httptest.NewRecorder()
  388. newSubscriptionTestRouter(subscriptionTestRouterConfig{
  389. jsonAutoDetect: true,
  390. jsonUserAgentRegex: `(?i)^jsonclient([ /]|$)`,
  391. }).ServeHTTP(resp, req)
  392. if resp.Code != http.StatusOK {
  393. t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
  394. }
  395. if got := resp.Header().Get("Content-Type"); got != "application/json; charset=utf-8" {
  396. t.Fatalf("Content-Type = %q, want JSON", got)
  397. }
  398. if body := strings.TrimSpace(resp.Body.String()); !strings.HasPrefix(body, "[") || !strings.Contains(body, `"outbounds"`) {
  399. t.Fatalf("auto-detected body is not an Xray JSON configuration array:\n%s", body)
  400. }
  401. })
  402. t.Run("explicit JSON endpoint preserves legacy single object by default", func(t *testing.T) {
  403. req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/json/s1", nil)
  404. resp := httptest.NewRecorder()
  405. newSubscriptionTestRouter(subscriptionTestRouterConfig{}).ServeHTTP(resp, req)
  406. if resp.Code != http.StatusOK {
  407. t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
  408. }
  409. if got := resp.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" {
  410. t.Fatalf("Content-Type = %q, want legacy text/plain", got)
  411. }
  412. if body := strings.TrimSpace(resp.Body.String()); !strings.HasPrefix(body, "{") {
  413. t.Fatalf("legacy explicit JSON body is not an object: %s", body)
  414. }
  415. })
  416. t.Run("explicit JSON endpoint can follow XTLS array standard", func(t *testing.T) {
  417. req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/json/s1", nil)
  418. resp := httptest.NewRecorder()
  419. newSubscriptionTestRouter(subscriptionTestRouterConfig{jsonAlwaysArray: true}).ServeHTTP(resp, req)
  420. if resp.Code != http.StatusOK {
  421. t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
  422. }
  423. if got := resp.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" {
  424. t.Fatalf("Content-Type = %q, want legacy text/plain", got)
  425. }
  426. if body := strings.TrimSpace(resp.Body.String()); !strings.HasPrefix(body, "[") {
  427. t.Fatalf("standards-compliant explicit JSON body is not an array: %s", body)
  428. }
  429. })
  430. }
  431. func TestExplicitMihomoAndLegacyClashEndpoints(t *testing.T) {
  432. seedSubDB(t)
  433. seedSubInbound(t, "s1", "vless", 4482, 1, `{"network":"tcp","security":"none"}`)
  434. seedSubProtocolInbound(t, "s1", "vmess", 4483, 2, `{"network":"ws","security":"tls","wsSettings":{"path":"/ws"},"tlsSettings":{"serverName":"vm.example.com"}}`, model.VMESS)
  435. gin.SetMode(gin.TestMode)
  436. router := newSubscriptionTestRouter(subscriptionTestRouterConfig{})
  437. for _, path := range []string{"/clash/s1", "/mihomo/s1"} {
  438. t.Run(path+" keeps the full Mihomo profile", func(t *testing.T) {
  439. req := httptest.NewRequest(http.MethodGet, "http://sub.example.com"+path, nil)
  440. resp := httptest.NewRecorder()
  441. router.ServeHTTP(resp, req)
  442. if resp.Code != http.StatusOK {
  443. t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
  444. }
  445. if body := resp.Body.String(); !strings.Contains(body, "type: vless") || !strings.Contains(body, "type: vmess") {
  446. t.Fatalf("full profile must keep VLESS and VMess:\n%s", body)
  447. }
  448. })
  449. }
  450. req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/clash-legacy/s1", nil)
  451. resp := httptest.NewRecorder()
  452. router.ServeHTTP(resp, req)
  453. if resp.Code != http.StatusOK {
  454. t.Fatalf("legacy status = %d, want 200; body=%s", resp.Code, resp.Body.String())
  455. }
  456. if body := resp.Body.String(); !strings.Contains(body, "type: vmess") || strings.Contains(body, "type: vless") {
  457. t.Fatalf("legacy profile must keep VMess and remove VLESS:\n%s", body)
  458. }
  459. }
  460. func TestLegacyClashEndpointExplainsWhenNoCompatibleProxyExists(t *testing.T) {
  461. seedSubDB(t)
  462. seedSubInbound(t, "s1", "vless", 4484, 1, `{"network":"tcp","security":"none"}`)
  463. gin.SetMode(gin.TestMode)
  464. req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/clash-legacy/s1", nil)
  465. resp := httptest.NewRecorder()
  466. newSubscriptionTestRouter(subscriptionTestRouterConfig{}).ServeHTTP(resp, req)
  467. if resp.Code != http.StatusUnprocessableEntity {
  468. t.Fatalf("status = %d, want 422; body=%s", resp.Code, resp.Body.String())
  469. }
  470. if !strings.Contains(resp.Body.String(), "no Clash for Windows-compatible proxies") {
  471. t.Fatalf("legacy endpoint did not explain the incompatibility: %s", resp.Body.String())
  472. }
  473. }
  474. func TestLegacyClashEndpointIgnoresCustomMihomoRouting(t *testing.T) {
  475. seedSubDB(t)
  476. seedSubProtocolInbound(t, "s1", "vmess", 4485, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"vm.example.com"}}`, model.VMESS)
  477. gin.SetMode(gin.TestMode)
  478. router := gin.New()
  479. NewSUBController(
  480. router.Group("/"),
  481. WithSUBClashEnabled(true),
  482. WithSUBClashEnableRouting(true),
  483. WithSUBClashRules(`
  484. proxies:
  485. - name: injected-modern-node
  486. type: vless
  487. server: modern.example.com
  488. port: 443
  489. uuid: 11111111-2222-4333-8444-555555555555
  490. proxy-groups:
  491. - name: MIHOMO-ONLY
  492. type: select
  493. include-all: true
  494. rules:
  495. - MATCH,MIHOMO-ONLY
  496. `),
  497. )
  498. req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/clash-legacy/s1", nil)
  499. resp := httptest.NewRecorder()
  500. router.ServeHTTP(resp, req)
  501. if resp.Code != http.StatusOK {
  502. t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
  503. }
  504. body := resp.Body.String()
  505. if strings.Contains(body, "injected-modern-node") || strings.Contains(body, "type: vless") || strings.Contains(body, "include-all") {
  506. t.Fatalf("custom Mihomo routing leaked into legacy profile:\n%s", body)
  507. }
  508. if !strings.Contains(body, "type: vmess") || !strings.Contains(body, "MATCH,PROXY") {
  509. t.Fatalf("legacy profile did not retain its compatible proxy and simple route:\n%s", body)
  510. }
  511. }
  512. func TestFormatEndpointsRawViewBypassesBrowserPage(t *testing.T) {
  513. seedSubDB(t)
  514. seedSubInbound(t, "s1", "raw", 4481, 1, `{"network":"tcp","security":"none"}`)
  515. gin.SetMode(gin.TestMode)
  516. oldDistFS := distFS
  517. distFS = testDistFS
  518. t.Cleanup(func() { distFS = oldDistFS })
  519. router := newSubscriptionTestRouter(subscriptionTestRouterConfig{})
  520. tests := []struct {
  521. name string
  522. path string
  523. contentType string
  524. disposition string
  525. bodyContains string
  526. }{
  527. {name: "JSON", path: "/json/s1?view=raw", contentType: "application/json; charset=utf-8", disposition: `attachment; filename="subscription.json"`, bodyContains: "outbounds"},
  528. {name: "Clash", path: "/clash/s1?view=raw", contentType: "application/yaml; charset=utf-8", disposition: `attachment; filename="subscription.yaml"`, bodyContains: "proxies:"},
  529. }
  530. for _, tt := range tests {
  531. t.Run(tt.name, func(t *testing.T) {
  532. req := httptest.NewRequest(http.MethodGet, "http://sub.example.com"+tt.path, nil)
  533. req.Header.Set("Accept", "text/html")
  534. resp := httptest.NewRecorder()
  535. router.ServeHTTP(resp, req)
  536. if resp.Code != http.StatusOK {
  537. t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
  538. }
  539. if got := resp.Header().Get("Content-Type"); got != tt.contentType {
  540. t.Fatalf("Content-Type = %q, want %q", got, tt.contentType)
  541. }
  542. if got := resp.Header().Get("Content-Disposition"); got != tt.disposition {
  543. t.Fatalf("Content-Disposition = %q, want %q", got, tt.disposition)
  544. }
  545. if !strings.Contains(resp.Body.String(), tt.bodyContains) {
  546. t.Fatalf("raw body does not contain %q: %s", tt.bodyContains, resp.Body.String())
  547. }
  548. })
  549. }
  550. for _, path := range []string{"/json/s1", "/clash/s1"} {
  551. t.Run(path+" browser page", func(t *testing.T) {
  552. req := httptest.NewRequest(http.MethodGet, "http://sub.example.com"+path, nil)
  553. req.Header.Set("Accept", "text/html")
  554. resp := httptest.NewRecorder()
  555. router.ServeHTTP(resp, req)
  556. if resp.Code != http.StatusOK {
  557. t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
  558. }
  559. if got := resp.Header().Get("Content-Type"); got != "text/html; charset=utf-8" {
  560. t.Fatalf("Content-Type = %q, want HTML", got)
  561. }
  562. })
  563. }
  564. }
  565. func writeFile(t *testing.T, path, content string) {
  566. t.Helper()
  567. if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
  568. t.Fatalf("write %s: %v", path, err)
  569. }
  570. }
  571. func renderTemplate(t *testing.T, a *SUBController, dir string, data map[string]any) string {
  572. t.Helper()
  573. tmpl, err := a.loadSubTemplate(dir)
  574. if err != nil {
  575. t.Fatalf("loadSubTemplate: unexpected error: %v", err)
  576. }
  577. if tmpl == nil {
  578. t.Fatal("loadSubTemplate: expected a template, got nil")
  579. }
  580. var buf bytes.Buffer
  581. if err := tmpl.Execute(&buf, data); err != nil {
  582. t.Fatalf("execute: %v", err)
  583. }
  584. return buf.String()
  585. }
  586. func TestLoadSubTemplate_RendersIndex(t *testing.T) {
  587. dir := t.TempDir()
  588. writeFile(t, filepath.Join(dir, "index.html"), `<h1>{{ .sId }}</h1>`)
  589. got := renderTemplate(t, newTestSUBController(), dir, map[string]any{"sId": "abc-123"})
  590. if want := `<h1>abc-123</h1>`; got != want {
  591. t.Fatalf("rendered = %q, want %q", got, want)
  592. }
  593. }
  594. func TestLoadSubTemplate_PrefersSubHTML(t *testing.T) {
  595. dir := t.TempDir()
  596. writeFile(t, filepath.Join(dir, "index.html"), `from-index`)
  597. writeFile(t, filepath.Join(dir, "sub.html"), `from-sub`)
  598. got := renderTemplate(t, newTestSUBController(), dir, nil)
  599. if got != "from-sub" {
  600. t.Fatalf("rendered = %q, want %q (sub.html should take precedence)", got, "from-sub")
  601. }
  602. }
  603. func TestLoadSubTemplate_FallbackCases(t *testing.T) {
  604. a := newTestSUBController()
  605. t.Run("missing dir", func(t *testing.T) {
  606. tmpl, err := a.loadSubTemplate(filepath.Join(t.TempDir(), "does-not-exist"))
  607. if tmpl != nil || err != nil {
  608. t.Fatalf("got (%v, %v), want (nil, nil)", tmpl, err)
  609. }
  610. })
  611. t.Run("path is a file not a dir", func(t *testing.T) {
  612. file := filepath.Join(t.TempDir(), "index.html")
  613. writeFile(t, file, `whatever`)
  614. tmpl, err := a.loadSubTemplate(file)
  615. if tmpl != nil || err != nil {
  616. t.Fatalf("got (%v, %v), want (nil, nil)", tmpl, err)
  617. }
  618. })
  619. t.Run("dir without template file", func(t *testing.T) {
  620. tmpl, err := a.loadSubTemplate(t.TempDir())
  621. if tmpl != nil || err != nil {
  622. t.Fatalf("got (%v, %v), want (nil, nil)", tmpl, err)
  623. }
  624. })
  625. }
  626. func TestLoadSubTemplate_MalformedTemplate(t *testing.T) {
  627. dir := t.TempDir()
  628. // Unterminated action — html/template fails to parse this.
  629. writeFile(t, filepath.Join(dir, "index.html"), `<h1>{{ .sId </h1>`)
  630. tmpl, err := newTestSUBController().loadSubTemplate(dir)
  631. if err == nil {
  632. t.Fatal("expected a parse error for a malformed template, got nil")
  633. }
  634. if tmpl != nil {
  635. t.Fatalf("expected nil template on parse error, got %v", tmpl)
  636. }
  637. }
  638. func TestLoadSubTemplate_CacheHitAndInvalidation(t *testing.T) {
  639. a := newTestSUBController()
  640. dir := t.TempDir()
  641. path := filepath.Join(dir, "index.html")
  642. // v1 with a fixed mtime.
  643. writeFile(t, path, `v1`)
  644. t1 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
  645. if err := os.Chtimes(path, t1, t1); err != nil {
  646. t.Fatalf("chtimes: %v", err)
  647. }
  648. first, err := a.loadSubTemplate(dir)
  649. if err != nil || first == nil {
  650. t.Fatalf("first load: (%v, %v)", first, err)
  651. }
  652. // Same mtime → cache hit returns the identical parsed template.
  653. second, err := a.loadSubTemplate(dir)
  654. if err != nil {
  655. t.Fatalf("second load: %v", err)
  656. }
  657. if second != first {
  658. t.Fatal("expected cache hit to return the same *template.Template pointer")
  659. }
  660. // New content + newer mtime → cache invalidated, fresh content served.
  661. writeFile(t, path, `v2`)
  662. t2 := t1.Add(time.Hour)
  663. if err := os.Chtimes(path, t2, t2); err != nil {
  664. t.Fatalf("chtimes: %v", err)
  665. }
  666. third, err := a.loadSubTemplate(dir)
  667. if err != nil || third == nil {
  668. t.Fatalf("third load: (%v, %v)", third, err)
  669. }
  670. if third == first {
  671. t.Fatal("expected cache invalidation to re-parse the template after mtime change")
  672. }
  673. var buf bytes.Buffer
  674. if err := third.Execute(&buf, nil); err != nil {
  675. t.Fatalf("execute: %v", err)
  676. }
  677. if buf.String() != "v2" {
  678. t.Fatalf("rendered = %q, want %q after edit", buf.String(), "v2")
  679. }
  680. }
  681. func TestStandardSubscriptionPreservesClashUserAgents(t *testing.T) {
  682. seedSubDB(t)
  683. seedSubProtocolInbound(t, "s1", "vm", 4905, 1, `{"network":"tcp","security":"none"}`, model.VMESS)
  684. gin.SetMode(gin.TestMode)
  685. router := newSubscriptionTestRouter(subscriptionTestRouterConfig{clashAutoDetect: true})
  686. for _, ua := range []string{"mihomo/1.19.12", "clash.meta", "Clash.Meta/1.19.12", "ClashX Meta/1.0", "ClashforWindows/0.20.39"} {
  687. t.Run(ua, func(t *testing.T) {
  688. req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
  689. req.Header.Set("User-Agent", ua)
  690. resp := httptest.NewRecorder()
  691. router.ServeHTTP(resp, req)
  692. if resp.Code != http.StatusOK || resp.Header().Get("Content-Type") != "application/yaml; charset=utf-8" || !strings.Contains(resp.Body.String(), "type: vmess") {
  693. 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())
  694. }
  695. })
  696. }
  697. }
  698. func TestLegacyClashEndpointNormalizesShadowsocksCipher(t *testing.T) {
  699. for _, method := range []string{"chacha20-ietf-poly1305", "chacha20-poly1305"} {
  700. t.Run(method, func(t *testing.T) {
  701. seedSubDB(t)
  702. ib := seedSubProtocolInbound(t, "s1", "ss", 4906, 1, `{"network":"tcp","security":"none"}`, model.Shadowsocks)
  703. db := database.GetDB()
  704. if err := db.Model(ib).Update("settings", fmt.Sprintf(`{"method":%q,"network":"tcp,udp"}`, method)).Error; err != nil {
  705. t.Fatal(err)
  706. }
  707. if err := db.Model(&model.ClientRecord{}).Where("email = ?", "ss@e").Update("password", "test-password").Error; err != nil {
  708. t.Fatal(err)
  709. }
  710. gin.SetMode(gin.TestMode)
  711. req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/clash-legacy/s1", nil)
  712. resp := httptest.NewRecorder()
  713. newSubscriptionTestRouter(subscriptionTestRouterConfig{}).ServeHTTP(resp, req)
  714. if resp.Code != http.StatusOK || !strings.Contains(resp.Body.String(), "type: ss") || !strings.Contains(resp.Body.String(), "cipher: chacha20-ietf-poly1305") {
  715. t.Fatalf("method=%s: status=%d, body=%s", method, resp.Code, resp.Body.String())
  716. }
  717. })
  718. }
  719. }