1
0

outbound_subscription_test.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  1. package service
  2. import (
  3. "bytes"
  4. "errors"
  5. "net/http"
  6. "net/http/httptest"
  7. "slices"
  8. "testing"
  9. "gorm.io/gorm"
  10. "github.com/mhsanaei/3x-ui/v3/internal/database"
  11. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  12. "github.com/mhsanaei/3x-ui/v3/internal/util/link"
  13. )
  14. func TestOutboundSubscriptionCreatePropagatesAllocationDatabaseFailures(t *testing.T) {
  15. setupSettingTestDB(t)
  16. db := database.GetDB()
  17. const callback = "test:fail_outbound_subscription_query"
  18. errInjected := errors.New("injected outbound subscription query failure")
  19. if err := db.Callback().Query().Before("gorm:query").Register(callback, func(tx *gorm.DB) {
  20. if tx.Statement != nil && tx.Statement.Table == "outbound_subscriptions" {
  21. tx.AddError(errInjected)
  22. }
  23. }); err != nil {
  24. t.Fatalf("register query callback: %v", err)
  25. }
  26. t.Cleanup(func() {
  27. if err := db.Callback().Query().Remove(callback); err != nil {
  28. t.Errorf("remove query callback: %v", err)
  29. }
  30. })
  31. for _, tc := range []struct {
  32. name string
  33. tagPrefix string
  34. operation string
  35. }{
  36. {name: "default prefix query", tagPrefix: "", operation: "prefix allocation"},
  37. {name: "priority count query", tagPrefix: "custom-", operation: "priority allocation"},
  38. } {
  39. t.Run(tc.name, func(t *testing.T) {
  40. created, err := (&OutboundSubscriptionService{}).Create("test", "https://1.1.1.1/sub", tc.tagPrefix, "", true, 600, false, false, false)
  41. if !errors.Is(err, errInjected) {
  42. t.Fatalf("Create error = %v, want injected %s query failure", err, tc.operation)
  43. }
  44. if created != nil {
  45. t.Fatalf("Create returned row %+v after %s query failure", created, tc.operation)
  46. }
  47. })
  48. }
  49. }
  50. func TestOutboundSubscriptionUpdatePropagatesPrefixQueryFailureWithoutMutation(t *testing.T) {
  51. setupSettingTestDB(t)
  52. db := database.GetDB()
  53. original := &model.OutboundSubscription{
  54. Remark: "before", Url: "https://1.1.1.1/original", TagPrefix: "custom-",
  55. Enabled: true, UpdateInterval: 600,
  56. }
  57. if err := db.Create(original).Error; err != nil {
  58. t.Fatalf("seed subscription: %v", err)
  59. }
  60. errInjected := errors.New("injected update prefix query failure")
  61. queryCount := 0
  62. const callback = "test:fail_update_prefix_query"
  63. if err := db.Callback().Query().Before("gorm:query").Register(callback, func(tx *gorm.DB) {
  64. if tx.Statement == nil || tx.Statement.Table != "outbound_subscriptions" {
  65. return
  66. }
  67. queryCount++
  68. if queryCount == 2 {
  69. tx.AddError(errInjected)
  70. }
  71. }); err != nil {
  72. t.Fatalf("register query callback: %v", err)
  73. }
  74. t.Cleanup(func() {
  75. if err := db.Callback().Query().Remove(callback); err != nil {
  76. t.Errorf("remove query callback: %v", err)
  77. }
  78. })
  79. err := (&OutboundSubscriptionService{}).Update(
  80. original.Id, "after", "https://1.1.1.1/changed", "", "", false, 1200, false, false, false,
  81. )
  82. if !errors.Is(err, errInjected) {
  83. t.Fatalf("Update error = %v, want injected prefix query failure", err)
  84. }
  85. if queryCount != 2 {
  86. t.Fatalf("outbound subscription queries = %d, want Get plus prefix allocation", queryCount)
  87. }
  88. var got model.OutboundSubscription
  89. if err := db.First(&got, original.Id).Error; err != nil {
  90. t.Fatalf("reload subscription: %v", err)
  91. }
  92. if got.Remark != original.Remark || got.Url != original.Url || got.TagPrefix != original.TagPrefix ||
  93. got.Enabled != original.Enabled || got.UpdateInterval != original.UpdateInterval {
  94. t.Fatalf("subscription changed after failed allocation: got %+v, want %+v", got, *original)
  95. }
  96. }
  97. func TestOutboundSubscriptionRefreshUsesCustomUserAgent(t *testing.T) {
  98. setupSettingTestDB(t)
  99. const wantUserAgent = "ClashMetaForAndroid/2.11.13"
  100. var gotUserAgent string
  101. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  102. gotUserAgent = r.UserAgent()
  103. _, _ = w.Write([]byte("vless://[email protected]:443?security=tls&type=tcp#node"))
  104. }))
  105. t.Cleanup(server.Close)
  106. sub := &model.OutboundSubscription{
  107. Url: server.URL, AllowPrivate: true, UserAgent: wantUserAgent, TagPrefix: "test-",
  108. }
  109. if err := database.GetDB().Create(sub).Error; err != nil {
  110. t.Fatalf("seed subscription: %v", err)
  111. }
  112. if _, err := (&OutboundSubscriptionService{}).Refresh(sub.Id); err != nil {
  113. t.Fatalf("Refresh: %v", err)
  114. }
  115. if gotUserAgent != wantUserAgent {
  116. t.Fatalf("User-Agent = %q, want %q", gotUserAgent, wantUserAgent)
  117. }
  118. }
  119. func TestReadBoundedOutboundSubscriptionBody(t *testing.T) {
  120. t.Run("accepts body at the limit", func(t *testing.T) {
  121. want := bytes.Repeat([]byte("a"), int(maxOutboundSubscriptionBytes))
  122. got, err := readBoundedOutboundSubscriptionBody(bytes.NewReader(want))
  123. if err != nil {
  124. t.Fatalf("readBoundedOutboundSubscriptionBody: %v", err)
  125. }
  126. if !bytes.Equal(got, want) {
  127. t.Fatalf("body mismatch: got %d bytes, want %d", len(got), len(want))
  128. }
  129. })
  130. t.Run("rejects body over the limit", func(t *testing.T) {
  131. body := bytes.Repeat([]byte("b"), int(maxOutboundSubscriptionBytes)+1)
  132. got, err := readBoundedOutboundSubscriptionBody(bytes.NewReader(body))
  133. if !errors.Is(err, errOutboundSubscriptionBodyTooLarge) {
  134. t.Fatalf("error = %v, want errOutboundSubscriptionBodyTooLarge", err)
  135. }
  136. if got != nil {
  137. t.Fatalf("oversized body returned %d bytes, want nil", len(got))
  138. }
  139. })
  140. }
  141. func TestDefaultPrefixNumber(t *testing.T) {
  142. mk := func(id int, prefix string) *model.OutboundSubscription {
  143. return &model.OutboundSubscription{Id: id, TagPrefix: prefix}
  144. }
  145. cases := []struct {
  146. name string
  147. subs []*model.OutboundSubscription
  148. excludeId int
  149. want int
  150. }{
  151. {"no subscriptions starts at 1", nil, 0, 1},
  152. {"sequential prefixes give the next", []*model.OutboundSubscription{mk(1, "sub1-"), mk(2, "sub2-")}, 0, 3},
  153. {"reuses the lowest freed number", []*model.OutboundSubscription{mk(2, "sub2-")}, 0, 1},
  154. {"legacy blank prefix reserves its id", []*model.OutboundSubscription{mk(1, ""), mk(5, "sub3-")}, 0, 2},
  155. {"custom prefixes are ignored", []*model.OutboundSubscription{mk(1, "hk-"), mk(2, "jp-")}, 0, 1},
  156. {"excludes the edited subscription", []*model.OutboundSubscription{mk(5, "sub2-")}, 5, 1},
  157. }
  158. for _, c := range cases {
  159. t.Run(c.name, func(t *testing.T) {
  160. if got := defaultPrefixNumber(c.subs, c.excludeId); got != c.want {
  161. t.Fatalf("got %d, want %d", got, c.want)
  162. }
  163. })
  164. }
  165. }
  166. func TestAssignStableTags(t *testing.T) {
  167. t.Run("reuses the tag mapped to a known identity", func(t *testing.T) {
  168. parsed := []link.Outbound{{"tag": "JP-Tokyo"}}
  169. prev := map[string]string{"id-abc": "sub1-keepme"}
  170. got := assignStableTags(parsed, []string{"id-abc"}, prev, nil, 1, "")
  171. if got[0] != "sub1-keepme" {
  172. t.Fatalf("got %q, want sub1-keepme", got[0])
  173. }
  174. if parsed[0]["tag"] != "sub1-keepme" {
  175. t.Fatalf("tag was not written back into the outbound: %v", parsed[0]["tag"])
  176. }
  177. })
  178. t.Run("falls back to the previous tag at the same position", func(t *testing.T) {
  179. parsed := []link.Outbound{{"tag": "JP-Tokyo"}}
  180. prev := map[string]string{"id-gone": "sub1-oldpos"}
  181. got := assignStableTags(parsed, []string{"id-new"}, prev, map[int]string{0: "sub1-oldpos"}, 1, "")
  182. if got[0] != "sub1-oldpos" {
  183. t.Fatalf("got %q, want sub1-oldpos", got[0])
  184. }
  185. })
  186. t.Run("does not let an inserted link steal a stable tag", func(t *testing.T) {
  187. parsed := []link.Outbound{{"tag": "Poland"}, {"tag": "NewServer"}, {"tag": "Netherlands"}}
  188. prev := map[string]string{
  189. "id-poland": "sub1-poland",
  190. "id-netherlands": "sub1-netherlands",
  191. }
  192. prevTagByIndex := map[int]string{0: "sub1-poland", 1: "sub1-netherlands"}
  193. got := assignStableTags(parsed, []string{"id-poland", "id-new", "id-netherlands"}, prev, prevTagByIndex, 1, "")
  194. want := []string{"sub1-poland", "sub1-newserver", "sub1-netherlands"}
  195. if !slices.Equal(got, want) {
  196. t.Fatalf("got %v, want %v", got, want)
  197. }
  198. })
  199. t.Run("does not let a fresh tag steal a stable tag", func(t *testing.T) {
  200. parsed := []link.Outbound{{"tag": "Netherlands"}, {"tag": "Renamed"}}
  201. prev := map[string]string{"id-netherlands": "sub1-netherlands"}
  202. got := assignStableTags(parsed, []string{"id-new", "id-netherlands"}, prev, nil, 1, "")
  203. want := []string{"sub1-netherlands-1", "sub1-netherlands"}
  204. if !slices.Equal(got, want) {
  205. t.Fatalf("got %v, want %v", got, want)
  206. }
  207. })
  208. t.Run("skips reserved tags while adding a suffix", func(t *testing.T) {
  209. parsed := []link.Outbound{{"tag": "Netherlands"}, {"tag": "First"}, {"tag": "Second"}}
  210. prev := map[string]string{
  211. "id-first": "sub1-netherlands",
  212. "id-second": "sub1-netherlands-1",
  213. }
  214. got := assignStableTags(parsed, []string{"id-new", "id-first", "id-second"}, prev, nil, 1, "")
  215. want := []string{"sub1-netherlands-2", "sub1-netherlands", "sub1-netherlands-1"}
  216. if !slices.Equal(got, want) {
  217. t.Fatalf("got %v, want %v", got, want)
  218. }
  219. })
  220. t.Run("allocates a fresh tag with the default sub<id>- prefix", func(t *testing.T) {
  221. parsed := []link.Outbound{{"tag": "Tokyo"}}
  222. got := assignStableTags(parsed, []string{"id-x"}, nil, nil, 7, "")
  223. want := link.SuggestTag("sub7-", "Tokyo", 0)
  224. if got[0] != want {
  225. t.Fatalf("got %q, want %q", got[0], want)
  226. }
  227. })
  228. t.Run("uses a custom prefix for fresh tags", func(t *testing.T) {
  229. parsed := []link.Outbound{{"tag": "Tokyo"}}
  230. got := assignStableTags(parsed, []string{"id-x"}, nil, nil, 1, "hk-")
  231. want := link.SuggestTag("hk-", "Tokyo", 0)
  232. if got[0] != want {
  233. t.Fatalf("got %q, want %q", got[0], want)
  234. }
  235. })
  236. t.Run("disambiguates colliding tags with a -N suffix", func(t *testing.T) {
  237. parsed := []link.Outbound{{"tag": "Same"}, {"tag": "Same"}}
  238. got := assignStableTags(parsed, []string{"id1", "id2"}, nil, nil, 1, "p-")
  239. base := link.SuggestTag("p-", "Same", 0)
  240. if got[0] != base {
  241. t.Fatalf("got[0] = %q, want %q", got[0], base)
  242. }
  243. if got[1] != base+"-1" {
  244. t.Fatalf("got[1] = %q, want %q", got[1], base+"-1")
  245. }
  246. })
  247. }
  248. // TestOutboundsContainTag covers the guard that ensures the outbound under test
  249. // is present in the HTTP-probe config. Subscription outbounds aren't part of the
  250. // template outbounds the frontend sends as allOutbounds, so the probe must append
  251. // the tested outbound when its tag is missing (otherwise burstObservatory has
  252. // nothing to probe and every subscription test times out).
  253. func TestOutboundsContainTag(t *testing.T) {
  254. template := []any{
  255. map[string]any{"tag": "direct", "protocol": "freedom"},
  256. map[string]any{"tag": "blocked", "protocol": "blackhole"},
  257. }
  258. if !outboundsContainTag(template, "direct") {
  259. t.Fatal("expected tag 'direct' to be found")
  260. }
  261. if outboundsContainTag(template, "sub1-tokyo") {
  262. t.Fatal("expected subscription tag to be absent from template outbounds")
  263. }
  264. if outboundsContainTag(nil, "anything") {
  265. t.Fatal("expected empty slice to contain no tags")
  266. }
  267. // Tolerates non-map / untagged entries without panicking.
  268. mixed := []any{"not-a-map", map[string]any{"protocol": "freedom"}}
  269. if outboundsContainTag(mixed, "direct") {
  270. t.Fatal("expected no match among untagged/non-map entries")
  271. }
  272. }
  273. // TestSanitizePublicHTTPURLRejectsPrivateAndBadSchemes covers the SSRF guard used
  274. // when fetching subscription URLs. All rejected cases use literal IPs or bad
  275. // schemes so the test never performs real DNS resolution.
  276. func TestSanitizePublicHTTPURLRejectsPrivateAndBadSchemes(t *testing.T) {
  277. rejected := []string{
  278. "http://127.0.0.1/sub", // loopback
  279. "http://10.0.0.1/x", // private
  280. "http://192.168.1.1", // private
  281. "http://169.254.169.254/latest/meta-data", // link-local (cloud metadata)
  282. "http://[::1]:8080/sub", // IPv6 loopback
  283. "http://0.0.0.0", // unspecified
  284. "ftp://example.com/x", // unsupported scheme
  285. "file:///etc/passwd", // unsupported scheme
  286. }
  287. for _, raw := range rejected {
  288. if _, err := SanitizePublicHTTPURL(raw, false); err == nil {
  289. t.Errorf("expected %q to be rejected, got nil error", raw)
  290. }
  291. }
  292. t.Run("allows a public literal IP without DNS", func(t *testing.T) {
  293. got, err := SanitizePublicHTTPURL("http://8.8.8.8/sub", false)
  294. if err != nil {
  295. t.Fatalf("unexpected error: %v", err)
  296. }
  297. if got != "http://8.8.8.8/sub" {
  298. t.Fatalf("got %q, want http://8.8.8.8/sub", got)
  299. }
  300. })
  301. }
  302. // outboundsContainTag mirrors the small helper in the outbound subpackage so
  303. // these subscription tests can assert on tag presence without importing it.
  304. func outboundsContainTag(outbounds []any, tag string) bool {
  305. for _, ob := range outbounds {
  306. if m, ok := ob.(map[string]any); ok {
  307. if t, _ := m["tag"].(string); t == tag {
  308. return true
  309. }
  310. }
  311. }
  312. return false
  313. }