outbound_subscription_test.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455
  1. package service
  2. import (
  3. "bytes"
  4. "encoding/base64"
  5. "errors"
  6. "fmt"
  7. "maps"
  8. "net/http"
  9. "net/http/httptest"
  10. "slices"
  11. "strings"
  12. "testing"
  13. "gorm.io/gorm"
  14. "github.com/mhsanaei/3x-ui/v3/internal/database"
  15. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  16. "github.com/mhsanaei/3x-ui/v3/internal/util/link"
  17. )
  18. func TestOutboundSubscriptionCreatePropagatesAllocationDatabaseFailures(t *testing.T) {
  19. setupSettingTestDB(t)
  20. db := database.GetDB()
  21. const callback = "test:fail_outbound_subscription_query"
  22. errInjected := errors.New("injected outbound subscription query failure")
  23. if err := db.Callback().Query().Before("gorm:query").Register(callback, func(tx *gorm.DB) {
  24. if tx.Statement != nil && tx.Statement.Table == "outbound_subscriptions" {
  25. tx.AddError(errInjected)
  26. }
  27. }); err != nil {
  28. t.Fatalf("register query callback: %v", err)
  29. }
  30. t.Cleanup(func() {
  31. if err := db.Callback().Query().Remove(callback); err != nil {
  32. t.Errorf("remove query callback: %v", err)
  33. }
  34. })
  35. for _, tc := range []struct {
  36. name string
  37. tagPrefix string
  38. operation string
  39. }{
  40. {name: "default prefix query", tagPrefix: "", operation: "prefix allocation"},
  41. {name: "priority count query", tagPrefix: "custom-", operation: "priority allocation"},
  42. } {
  43. t.Run(tc.name, func(t *testing.T) {
  44. created, err := (&OutboundSubscriptionService{}).Create("test", "https://1.1.1.1/sub", tc.tagPrefix, "", true, 600, false, false, false)
  45. if !errors.Is(err, errInjected) {
  46. t.Fatalf("Create error = %v, want injected %s query failure", err, tc.operation)
  47. }
  48. if created != nil {
  49. t.Fatalf("Create returned row %+v after %s query failure", created, tc.operation)
  50. }
  51. })
  52. }
  53. }
  54. func TestOutboundSubscriptionUpdatePropagatesPrefixQueryFailureWithoutMutation(t *testing.T) {
  55. setupSettingTestDB(t)
  56. db := database.GetDB()
  57. original := &model.OutboundSubscription{
  58. Remark: "before", Url: "https://1.1.1.1/original", TagPrefix: "custom-",
  59. Enabled: true, UpdateInterval: 600,
  60. }
  61. if err := db.Create(original).Error; err != nil {
  62. t.Fatalf("seed subscription: %v", err)
  63. }
  64. errInjected := errors.New("injected update prefix query failure")
  65. queryCount := 0
  66. const callback = "test:fail_update_prefix_query"
  67. if err := db.Callback().Query().Before("gorm:query").Register(callback, func(tx *gorm.DB) {
  68. if tx.Statement == nil || tx.Statement.Table != "outbound_subscriptions" {
  69. return
  70. }
  71. queryCount++
  72. if queryCount == 2 {
  73. tx.AddError(errInjected)
  74. }
  75. }); err != nil {
  76. t.Fatalf("register query callback: %v", err)
  77. }
  78. t.Cleanup(func() {
  79. if err := db.Callback().Query().Remove(callback); err != nil {
  80. t.Errorf("remove query callback: %v", err)
  81. }
  82. })
  83. err := (&OutboundSubscriptionService{}).Update(
  84. original.Id, "after", "https://1.1.1.1/changed", "", "", false, 1200, false, false, false,
  85. )
  86. if !errors.Is(err, errInjected) {
  87. t.Fatalf("Update error = %v, want injected prefix query failure", err)
  88. }
  89. if queryCount != 2 {
  90. t.Fatalf("outbound subscription queries = %d, want Get plus prefix allocation", queryCount)
  91. }
  92. var got model.OutboundSubscription
  93. if err := db.First(&got, original.Id).Error; err != nil {
  94. t.Fatalf("reload subscription: %v", err)
  95. }
  96. if got.Remark != original.Remark || got.Url != original.Url || got.TagPrefix != original.TagPrefix ||
  97. got.Enabled != original.Enabled || got.UpdateInterval != original.UpdateInterval {
  98. t.Fatalf("subscription changed after failed allocation: got %+v, want %+v", got, *original)
  99. }
  100. }
  101. func TestOutboundSubscriptionRefreshUsesCustomUserAgent(t *testing.T) {
  102. setupSettingTestDB(t)
  103. const wantUserAgent = "ClashMetaForAndroid/2.11.13"
  104. var gotUserAgent string
  105. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  106. gotUserAgent = r.UserAgent()
  107. _, _ = w.Write([]byte("vless://[email protected]:443?security=tls&type=tcp#node"))
  108. }))
  109. t.Cleanup(server.Close)
  110. sub := &model.OutboundSubscription{
  111. Url: server.URL, AllowPrivate: true, UserAgent: wantUserAgent, TagPrefix: "test-",
  112. }
  113. if err := database.GetDB().Create(sub).Error; err != nil {
  114. t.Fatalf("seed subscription: %v", err)
  115. }
  116. if _, err := (&OutboundSubscriptionService{}).Refresh(sub.Id); err != nil {
  117. t.Fatalf("Refresh: %v", err)
  118. }
  119. if gotUserAgent != wantUserAgent {
  120. t.Fatalf("User-Agent = %q, want %q", gotUserAgent, wantUserAgent)
  121. }
  122. }
  123. // serveOutboundSubscription seeds a subscription whose URL returns body(n) for the n-th fetch.
  124. func serveOutboundSubscription(t *testing.T, tagPrefix string, body func(n int) string) int {
  125. t.Helper()
  126. requests := 0
  127. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  128. requests++
  129. _, _ = w.Write([]byte(body(requests)))
  130. }))
  131. t.Cleanup(server.Close)
  132. sub := &model.OutboundSubscription{Url: server.URL, AllowPrivate: true, TagPrefix: tagPrefix}
  133. if err := database.GetDB().Create(sub).Error; err != nil {
  134. t.Fatalf("seed subscription: %v", err)
  135. }
  136. return sub.Id
  137. }
  138. func refreshOutboundTags(t *testing.T, subID int) (tags []string, byAddress map[string]string) {
  139. t.Helper()
  140. obs, err := (&OutboundSubscriptionService{}).Refresh(subID)
  141. if err != nil {
  142. t.Fatalf("Refresh: %v", err)
  143. }
  144. byAddress = map[string]string{}
  145. for _, ob := range obs {
  146. m := ob.(map[string]any)
  147. tag, _ := m["tag"].(string)
  148. address, _ := m["settings"].(map[string]any)["address"].(string)
  149. tags = append(tags, tag)
  150. byAddress[address] = tag
  151. }
  152. return tags, byAddress
  153. }
  154. func TestOutboundSubscriptionRefreshKeepsTagsWhenRealityParamsRotate(t *testing.T) {
  155. setupSettingTestDB(t)
  156. pbk := base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32))
  157. type server struct{ remark, address string }
  158. var servers []server
  159. // A 3x-ui upstream picks sid and sni at random per request, and older releases spx too (#6556).
  160. subID := serveOutboundSubscription(t, "sub", func(n int) string {
  161. lines := make([]string, 0, len(servers))
  162. for _, s := range servers {
  163. lines = append(lines, fmt.Sprintf(
  164. "vless://00000000-0000-4000-8000-000000000000@%s:443?type=tcp&security=reality&pbk=%s&fp=chrome&sni=sni%d.example.com&sid=%02x&spx=%%2F%d#%s",
  165. s.address, pbk, n, n, n, s.remark))
  166. }
  167. return strings.Join(lines, "\n")
  168. })
  169. steps := []struct {
  170. name string
  171. servers []server
  172. want map[string]string
  173. }{
  174. {
  175. "initial fetch",
  176. []server{{"France", "1.1.1.1"}, {"Germany", "8.8.8.8"}, {"Sweden", "9.9.9.9"}},
  177. map[string]string{"1.1.1.1": "sub-france", "8.8.8.8": "sub-germany", "9.9.9.9": "sub-sweden"},
  178. },
  179. {
  180. "France removed",
  181. []server{{"Germany", "8.8.8.8"}, {"Sweden", "9.9.9.9"}},
  182. map[string]string{"8.8.8.8": "sub-germany", "9.9.9.9": "sub-sweden"},
  183. },
  184. {
  185. "new France added first",
  186. []server{{"France", "1.0.0.1"}, {"Germany", "8.8.8.8"}, {"Sweden", "9.9.9.9"}},
  187. map[string]string{"1.0.0.1": "sub-france", "8.8.8.8": "sub-germany", "9.9.9.9": "sub-sweden"},
  188. },
  189. }
  190. for _, step := range steps {
  191. servers = step.servers
  192. if _, got := refreshOutboundTags(t, subID); !maps.Equal(got, step.want) {
  193. t.Fatalf("%s: tags by address = %v, want %v", step.name, got, step.want)
  194. }
  195. }
  196. }
  197. func TestOutboundSubscriptionRefreshKeepsTagsOfRepeatedLink(t *testing.T) {
  198. setupSettingTestDB(t)
  199. const link = "vless://[email protected]:443?security=tls&type=tcp"
  200. subID := serveOutboundSubscription(t, "p-", func(int) string { return link + "#A\n" + link + "#B" })
  201. want := []string{"p-a", "p-b"}
  202. for refresh := 1; refresh <= 3; refresh++ {
  203. if got, _ := refreshOutboundTags(t, subID); !slices.Equal(got, want) {
  204. t.Fatalf("refresh %d: tags = %v, want %v", refresh, got, want)
  205. }
  206. }
  207. }
  208. func TestOutboundSubscriptionRefreshAlignsPositionsPastCoreRejectedLink(t *testing.T) {
  209. setupSettingTestDB(t)
  210. // The unencrypted first link is dropped by the core; B and C then rotate their UUID.
  211. subID := serveOutboundSubscription(t, "p-", func(n int) string {
  212. uuid := fmt.Sprintf("00000000-0000-4000-8000-%012d", n)
  213. return "vless://[email protected]:443?security=none&type=tcp#Plain\n" +
  214. "vless://" + uuid + "@8.8.8.8:443?security=tls&type=tcp#B\n" +
  215. "vless://" + uuid + "@9.9.9.9:443?security=tls&type=tcp#C"
  216. })
  217. want := map[string]string{"8.8.8.8": "p-b", "9.9.9.9": "p-c"}
  218. for refresh := 1; refresh <= 2; refresh++ {
  219. if _, got := refreshOutboundTags(t, subID); !maps.Equal(got, want) {
  220. t.Fatalf("refresh %d: tags by address = %v, want %v", refresh, got, want)
  221. }
  222. }
  223. }
  224. func TestReadBoundedOutboundSubscriptionBody(t *testing.T) {
  225. t.Run("accepts body at the limit", func(t *testing.T) {
  226. want := bytes.Repeat([]byte("a"), int(maxOutboundSubscriptionBytes))
  227. got, err := readBoundedOutboundSubscriptionBody(bytes.NewReader(want))
  228. if err != nil {
  229. t.Fatalf("readBoundedOutboundSubscriptionBody: %v", err)
  230. }
  231. if !bytes.Equal(got, want) {
  232. t.Fatalf("body mismatch: got %d bytes, want %d", len(got), len(want))
  233. }
  234. })
  235. t.Run("rejects body over the limit", func(t *testing.T) {
  236. body := bytes.Repeat([]byte("b"), int(maxOutboundSubscriptionBytes)+1)
  237. got, err := readBoundedOutboundSubscriptionBody(bytes.NewReader(body))
  238. if !errors.Is(err, errOutboundSubscriptionBodyTooLarge) {
  239. t.Fatalf("error = %v, want errOutboundSubscriptionBodyTooLarge", err)
  240. }
  241. if got != nil {
  242. t.Fatalf("oversized body returned %d bytes, want nil", len(got))
  243. }
  244. })
  245. }
  246. func TestDefaultPrefixNumber(t *testing.T) {
  247. mk := func(id int, prefix string) *model.OutboundSubscription {
  248. return &model.OutboundSubscription{Id: id, TagPrefix: prefix}
  249. }
  250. cases := []struct {
  251. name string
  252. subs []*model.OutboundSubscription
  253. excludeId int
  254. want int
  255. }{
  256. {"no subscriptions starts at 1", nil, 0, 1},
  257. {"sequential prefixes give the next", []*model.OutboundSubscription{mk(1, "sub1-"), mk(2, "sub2-")}, 0, 3},
  258. {"reuses the lowest freed number", []*model.OutboundSubscription{mk(2, "sub2-")}, 0, 1},
  259. {"legacy blank prefix reserves its id", []*model.OutboundSubscription{mk(1, ""), mk(5, "sub3-")}, 0, 2},
  260. {"custom prefixes are ignored", []*model.OutboundSubscription{mk(1, "hk-"), mk(2, "jp-")}, 0, 1},
  261. {"excludes the edited subscription", []*model.OutboundSubscription{mk(5, "sub2-")}, 5, 1},
  262. }
  263. for _, c := range cases {
  264. t.Run(c.name, func(t *testing.T) {
  265. if got := defaultPrefixNumber(c.subs, c.excludeId); got != c.want {
  266. t.Fatalf("got %d, want %d", got, c.want)
  267. }
  268. })
  269. }
  270. }
  271. func TestAssignStableTags(t *testing.T) {
  272. t.Run("reuses the tag mapped to a known identity", func(t *testing.T) {
  273. parsed := []link.Outbound{{"tag": "JP-Tokyo"}}
  274. prev := map[string]string{"id-abc": "sub1-keepme"}
  275. got := assignStableTags(parsed, []string{"id-abc"}, prev, nil, 1, "")
  276. if got[0] != "sub1-keepme" {
  277. t.Fatalf("got %q, want sub1-keepme", got[0])
  278. }
  279. if parsed[0]["tag"] != "sub1-keepme" {
  280. t.Fatalf("tag was not written back into the outbound: %v", parsed[0]["tag"])
  281. }
  282. })
  283. t.Run("falls back to the previous tag at the same position", func(t *testing.T) {
  284. parsed := []link.Outbound{{"tag": "JP-Tokyo"}}
  285. prev := map[string]string{"id-gone": "sub1-oldpos"}
  286. got := assignStableTags(parsed, []string{"id-new"}, prev, map[int]string{0: "sub1-oldpos"}, 1, "")
  287. if got[0] != "sub1-oldpos" {
  288. t.Fatalf("got %q, want sub1-oldpos", got[0])
  289. }
  290. })
  291. t.Run("does not let an inserted link steal a stable tag", func(t *testing.T) {
  292. parsed := []link.Outbound{{"tag": "Poland"}, {"tag": "NewServer"}, {"tag": "Netherlands"}}
  293. prev := map[string]string{
  294. "id-poland": "sub1-poland",
  295. "id-netherlands": "sub1-netherlands",
  296. }
  297. prevTagByIndex := map[int]string{0: "sub1-poland", 1: "sub1-netherlands"}
  298. got := assignStableTags(parsed, []string{"id-poland", "id-new", "id-netherlands"}, prev, prevTagByIndex, 1, "")
  299. want := []string{"sub1-poland", "sub1-newserver", "sub1-netherlands"}
  300. if !slices.Equal(got, want) {
  301. t.Fatalf("got %v, want %v", got, want)
  302. }
  303. })
  304. t.Run("does not let a fresh tag steal a stable tag", func(t *testing.T) {
  305. parsed := []link.Outbound{{"tag": "Netherlands"}, {"tag": "Renamed"}}
  306. prev := map[string]string{"id-netherlands": "sub1-netherlands"}
  307. got := assignStableTags(parsed, []string{"id-new", "id-netherlands"}, prev, nil, 1, "")
  308. want := []string{"sub1-netherlands-1", "sub1-netherlands"}
  309. if !slices.Equal(got, want) {
  310. t.Fatalf("got %v, want %v", got, want)
  311. }
  312. })
  313. t.Run("skips reserved tags while adding a suffix", func(t *testing.T) {
  314. parsed := []link.Outbound{{"tag": "Netherlands"}, {"tag": "First"}, {"tag": "Second"}}
  315. prev := map[string]string{
  316. "id-first": "sub1-netherlands",
  317. "id-second": "sub1-netherlands-1",
  318. }
  319. got := assignStableTags(parsed, []string{"id-new", "id-first", "id-second"}, prev, nil, 1, "")
  320. want := []string{"sub1-netherlands-2", "sub1-netherlands", "sub1-netherlands-1"}
  321. if !slices.Equal(got, want) {
  322. t.Fatalf("got %v, want %v", got, want)
  323. }
  324. })
  325. t.Run("allocates a fresh tag with the default sub<id>- prefix", func(t *testing.T) {
  326. parsed := []link.Outbound{{"tag": "Tokyo"}}
  327. got := assignStableTags(parsed, []string{"id-x"}, nil, nil, 7, "")
  328. want := link.SuggestTag("sub7-", "Tokyo", 0)
  329. if got[0] != want {
  330. t.Fatalf("got %q, want %q", got[0], want)
  331. }
  332. })
  333. t.Run("uses a custom prefix for fresh tags", func(t *testing.T) {
  334. parsed := []link.Outbound{{"tag": "Tokyo"}}
  335. got := assignStableTags(parsed, []string{"id-x"}, nil, nil, 1, "hk-")
  336. want := link.SuggestTag("hk-", "Tokyo", 0)
  337. if got[0] != want {
  338. t.Fatalf("got %q, want %q", got[0], want)
  339. }
  340. })
  341. t.Run("disambiguates colliding tags with a -N suffix", func(t *testing.T) {
  342. parsed := []link.Outbound{{"tag": "Same"}, {"tag": "Same"}}
  343. got := assignStableTags(parsed, []string{"id1", "id2"}, nil, nil, 1, "p-")
  344. base := link.SuggestTag("p-", "Same", 0)
  345. if got[0] != base {
  346. t.Fatalf("got[0] = %q, want %q", got[0], base)
  347. }
  348. if got[1] != base+"-1" {
  349. t.Fatalf("got[1] = %q, want %q", got[1], base+"-1")
  350. }
  351. })
  352. }
  353. // TestOutboundsContainTag covers the guard that ensures the outbound under test
  354. // is present in the HTTP-probe config. Subscription outbounds aren't part of the
  355. // template outbounds the frontend sends as allOutbounds, so the probe must append
  356. // the tested outbound when its tag is missing (otherwise burstObservatory has
  357. // nothing to probe and every subscription test times out).
  358. func TestOutboundsContainTag(t *testing.T) {
  359. template := []any{
  360. map[string]any{"tag": "direct", "protocol": "freedom"},
  361. map[string]any{"tag": "blocked", "protocol": "blackhole"},
  362. }
  363. if !outboundsContainTag(template, "direct") {
  364. t.Fatal("expected tag 'direct' to be found")
  365. }
  366. if outboundsContainTag(template, "sub1-tokyo") {
  367. t.Fatal("expected subscription tag to be absent from template outbounds")
  368. }
  369. if outboundsContainTag(nil, "anything") {
  370. t.Fatal("expected empty slice to contain no tags")
  371. }
  372. // Tolerates non-map / untagged entries without panicking.
  373. mixed := []any{"not-a-map", map[string]any{"protocol": "freedom"}}
  374. if outboundsContainTag(mixed, "direct") {
  375. t.Fatal("expected no match among untagged/non-map entries")
  376. }
  377. }
  378. // TestSanitizePublicHTTPURLRejectsPrivateAndBadSchemes covers the SSRF guard used
  379. // when fetching subscription URLs. All rejected cases use literal IPs or bad
  380. // schemes so the test never performs real DNS resolution.
  381. func TestSanitizePublicHTTPURLRejectsPrivateAndBadSchemes(t *testing.T) {
  382. rejected := []string{
  383. "http://127.0.0.1/sub", // loopback
  384. "http://10.0.0.1/x", // private
  385. "http://192.168.1.1", // private
  386. "http://169.254.169.254/latest/meta-data", // link-local (cloud metadata)
  387. "http://[::1]:8080/sub", // IPv6 loopback
  388. "http://0.0.0.0", // unspecified
  389. "ftp://example.com/x", // unsupported scheme
  390. "file:///etc/passwd", // unsupported scheme
  391. }
  392. for _, raw := range rejected {
  393. if _, err := SanitizePublicHTTPURL(raw, false); err == nil {
  394. t.Errorf("expected %q to be rejected, got nil error", raw)
  395. }
  396. }
  397. t.Run("allows a public literal IP without DNS", func(t *testing.T) {
  398. got, err := SanitizePublicHTTPURL("http://8.8.8.8/sub", false)
  399. if err != nil {
  400. t.Fatalf("unexpected error: %v", err)
  401. }
  402. if got != "http://8.8.8.8/sub" {
  403. t.Fatalf("got %q, want http://8.8.8.8/sub", got)
  404. }
  405. })
  406. }
  407. // outboundsContainTag mirrors the small helper in the outbound subpackage so
  408. // these subscription tests can assert on tag presence without importing it.
  409. func outboundsContainTag(outbounds []any, tag string) bool {
  410. for _, ob := range outbounds {
  411. if m, ok := ob.(map[string]any); ok {
  412. if t, _ := m["tag"].(string); t == tag {
  413. return true
  414. }
  415. }
  416. }
  417. return false
  418. }