client_create_fanout_test.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340
  1. package service
  2. import (
  3. "context"
  4. "fmt"
  5. "strings"
  6. "sync/atomic"
  7. "testing"
  8. "time"
  9. "github.com/mhsanaei/3x-ui/v3/internal/database"
  10. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  11. "github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
  12. )
  13. func TestCreateAcrossManyInboundsUsesOneEmailSnapshot(t *testing.T) {
  14. setupBulkDB(t)
  15. svc := &ClientService{}
  16. inboundSvc := &InboundService{}
  17. const uuid = "bbbbbbbb-1111-2222-3333-555555555555"
  18. ids := make([]int, 0, 6)
  19. for i := range 6 {
  20. ib := mkInbound(t, 23001+i, model.VLESS, `{"clients":[]}`)
  21. ids = append(ids, ib.Id)
  22. }
  23. if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
  24. Client: model.Client{Email: "fan@x", ID: uuid, SubID: "sub-fan", Enable: true},
  25. InboundIds: ids,
  26. }); err != nil {
  27. t.Fatalf("Create across %d inbounds: %v", len(ids), err)
  28. }
  29. if n := countClientRecords(t); n != 1 {
  30. t.Fatalf("client records = %d, want 1", n)
  31. }
  32. rec := lookupClientRecord(t, "fan@x")
  33. if rec.UUID != uuid || rec.SubID != "sub-fan" {
  34. t.Fatalf("record = {uuid:%q sub:%q}, want {%q sub-fan}", rec.UUID, rec.SubID, uuid)
  35. }
  36. for _, id := range ids {
  37. if !settingsHoldUUID(t, inboundSvc, id, uuid) {
  38. t.Fatalf("inbound %d settings missing the client", id)
  39. }
  40. }
  41. linked, err := svc.GetInboundIdsForRecord(rec.Id)
  42. if err != nil {
  43. t.Fatalf("GetInboundIdsForRecord: %v", err)
  44. }
  45. if len(linked) != len(ids) {
  46. t.Fatalf("linked inbounds = %d, want %d", len(linked), len(ids))
  47. }
  48. }
  49. func TestAttachAcrossManyInboundsUsesOneEmailSnapshot(t *testing.T) {
  50. setupBulkDB(t)
  51. svc := &ClientService{}
  52. inboundSvc := &InboundService{}
  53. first := mkInbound(t, 23101, model.VLESS, `{"clients":[]}`)
  54. if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
  55. Client: model.Client{Email: "att@x", ID: "cccccccc-1111-2222-3333-666666666666", SubID: "sub-att", Enable: true},
  56. InboundIds: []int{first.Id},
  57. }); err != nil {
  58. t.Fatalf("seed Create: %v", err)
  59. }
  60. rec := lookupClientRecord(t, "att@x")
  61. ids := []int{first.Id}
  62. for i := range 4 {
  63. ib := mkInbound(t, 23102+i, model.VLESS, `{"clients":[]}`)
  64. ids = append(ids, ib.Id)
  65. }
  66. if _, err := svc.Attach(inboundSvc, rec.Id, ids); err != nil {
  67. t.Fatalf("Attach across %d inbounds: %v", len(ids), err)
  68. }
  69. if n := countClientRecords(t); n != 1 {
  70. t.Fatalf("client records after attach = %d, want 1", n)
  71. }
  72. linked, err := svc.GetInboundIdsForRecord(rec.Id)
  73. if err != nil {
  74. t.Fatalf("GetInboundIdsForRecord: %v", err)
  75. }
  76. if len(linked) != len(ids) {
  77. t.Fatalf("linked inbounds = %d, want %d", len(linked), len(ids))
  78. }
  79. }
  80. // barrierNodeRuntime holds every AddClient until fanout of them are inside it at
  81. // once, recording the peak overlap; a sequential caller only ever reaches one.
  82. type barrierNodeRuntime struct {
  83. fakeNodeRuntime
  84. fanout int32
  85. inFlight atomic.Int32
  86. maxPar atomic.Int32
  87. release chan struct{}
  88. freed atomic.Bool
  89. expired atomic.Bool
  90. }
  91. func (b *barrierNodeRuntime) free() {
  92. if b.freed.CompareAndSwap(false, true) {
  93. close(b.release)
  94. }
  95. }
  96. func (b *barrierNodeRuntime) AddClient(ctx context.Context, ib *model.Inbound, c model.Client) error {
  97. n := b.inFlight.Add(1)
  98. for {
  99. peak := b.maxPar.Load()
  100. if n <= peak || b.maxPar.CompareAndSwap(peak, n) {
  101. break
  102. }
  103. }
  104. if n == b.fanout {
  105. b.free()
  106. }
  107. select {
  108. case <-b.release:
  109. case <-time.After(5 * time.Second):
  110. // Release everyone on the first timeout so a sequential regression
  111. // fails once instead of stalling for fanout x the wait.
  112. b.expired.Store(true)
  113. b.free()
  114. }
  115. b.inFlight.Add(-1)
  116. return b.fakeNodeRuntime.AddClient(ctx, ib, c)
  117. }
  118. func fanoutNodeInbounds(t *testing.T, mgr *runtime.Manager, rt runtime.Runtime, n int, basePort int) []int {
  119. t.Helper()
  120. ids := make([]int, 0, n)
  121. for i := range n {
  122. node := &model.Node{
  123. Name: fmt.Sprintf("%s-%d", t.Name(), i), Address: "127.0.0.1", Port: 2096 + i,
  124. ApiToken: "tok", Enable: true, Status: "online",
  125. }
  126. if err := database.GetDB().Create(node).Error; err != nil {
  127. t.Fatalf("create node %d: %v", i, err)
  128. }
  129. mgr.SetRuntimeOverride(node.Id, rt)
  130. ids = append(ids, nodeInbound(t, node.Id, basePort+i, nil).Id)
  131. }
  132. return ids
  133. }
  134. // TestCreateAcrossNodesPushesConcurrently pins that a client spanning several
  135. // node inbounds pushes to them at once, up to inboundFanoutConcurrency at a time.
  136. func TestCreateAcrossNodesPushesConcurrently(t *testing.T) {
  137. setupBulkDB(t)
  138. startSerializedWriter(t)
  139. mgr := useTestRuntimeManager(t)
  140. const nodes = inboundFanoutConcurrency + 1
  141. bar := &barrierNodeRuntime{fanout: inboundFanoutConcurrency, release: make(chan struct{})}
  142. ids := fanoutNodeInbounds(t, mgr, bar, nodes, 40101)
  143. if _, err := (&ClientService{}).Create(&InboundService{}, &ClientCreatePayload{
  144. Client: model.Client{Email: "fanout@x", ID: "11111111-2222-3333-4444-555555555555", SubID: "sub-fanout", Enable: true},
  145. InboundIds: ids,
  146. }); err != nil {
  147. t.Fatalf("Create across %d node inbounds: %v", nodes, err)
  148. }
  149. if got := bar.addClient.Load(); got != nodes {
  150. t.Fatalf("AddClient pushes = %d, want %d", got, nodes)
  151. }
  152. if got := bar.maxPar.Load(); got < 2 || got != inboundFanoutConcurrency {
  153. t.Fatalf("peak node pushes in flight = %d, want overlap at the %d cap (barrier timed out: %v)",
  154. got, inboundFanoutConcurrency, bar.expired.Load())
  155. }
  156. }
  157. // TestCreateRecoversPanicInOneInbound pins that a panicking inbound fails only
  158. // itself: off the request goroutine nothing else would catch it.
  159. func TestCreateRecoversPanicInOneInbound(t *testing.T) {
  160. setupBulkDB(t)
  161. startSerializedWriter(t)
  162. mgr := useTestRuntimeManager(t)
  163. node := &model.Node{
  164. Name: t.Name(), Address: "127.0.0.1", Port: 2096,
  165. ApiToken: "tok", Enable: true, Status: "online",
  166. }
  167. if err := database.GetDB().Create(node).Error; err != nil {
  168. t.Fatalf("create node: %v", err)
  169. }
  170. mgr.SetRuntimeOverride(node.Id, &panicNodeRuntime{})
  171. boom := nodeInbound(t, node.Id, 40201, nil)
  172. healthy := mkInbound(t, 40202, model.VLESS, `{"clients":[]}`)
  173. const uuid = "33333333-4444-5555-6666-777777777777"
  174. _, err := (&ClientService{}).Create(&InboundService{}, &ClientCreatePayload{
  175. Client: model.Client{Email: "panic@x", ID: uuid, SubID: "sub-panic", Enable: true},
  176. InboundIds: []int{boom.Id, healthy.Id},
  177. })
  178. if err == nil {
  179. t.Fatal("a panicking node runtime produced no error")
  180. }
  181. if want := fmt.Sprintf("inbound %d: panic:", boom.Id); !strings.Contains(err.Error(), want) {
  182. t.Fatalf("error %q does not report %q", err, want)
  183. }
  184. if !settingsHoldUUID(t, &InboundService{}, healthy.Id, uuid) {
  185. t.Fatalf("healthy inbound %d did not get the client", healthy.Id)
  186. }
  187. }
  188. // TestCreateLeavesHwidLimitAloneWhenCreateFails pins that a create the panel
  189. // reported as failed never rewrites a device cap, so it can never retrim one.
  190. func TestCreateLeavesHwidLimitAloneWhenCreateFails(t *testing.T) {
  191. setupBulkDB(t)
  192. startSerializedWriter(t)
  193. svc := &ClientService{}
  194. inboundSvc := &InboundService{}
  195. const vipUUID = "44444444-5555-6666-7777-888888888888"
  196. seed := mkInbound(t, 41401, model.VLESS, `{"clients":[]}`)
  197. if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
  198. Client: model.Client{Email: "vip@x", ID: vipUUID, SubID: "sub-vip", Enable: true},
  199. InboundIds: []int{seed.Id},
  200. LimitHwid: 3,
  201. }); err != nil {
  202. t.Fatalf("seed Create: %v", err)
  203. }
  204. broken := mkInbound(t, 41402, model.VLESS, `{"clients":`)
  205. if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
  206. Client: model.Client{Email: "vip@x", ID: vipUUID, SubID: "sub-vip", Enable: true},
  207. InboundIds: []int{broken.Id},
  208. LimitHwid: 1,
  209. }); err == nil {
  210. t.Fatal("re-adding to an unparsable inbound returned no error")
  211. }
  212. if rec := lookupClientRecord(t, "vip@x"); rec.LimitHwid != 3 {
  213. t.Fatalf("limit_hwid = %d, want the untouched 3: a failed create retrimmed a live client", rec.LimitHwid)
  214. }
  215. // Same failure with the seeded inbound alongside it: that one is a dedup
  216. // no-op returning no error, which must not read as "an inbound took it".
  217. if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
  218. Client: model.Client{Email: "vip@x", ID: vipUUID, SubID: "sub-vip", Enable: true},
  219. InboundIds: []int{seed.Id, broken.Id},
  220. LimitHwid: 1,
  221. }); err == nil {
  222. t.Fatal("re-adding over a no-op and an unparsable inbound returned no error")
  223. }
  224. if rec := lookupClientRecord(t, "vip@x"); rec.LimitHwid != 3 {
  225. t.Fatalf("limit_hwid = %d, want the untouched 3: a no-op inbound counted as applied", rec.LimitHwid)
  226. }
  227. // A brand new identity that only partly applies is left uncapped rather than
  228. // capped, the deliberate safe side: the operator saw the error and retries.
  229. healthy := mkInbound(t, 41403, model.VLESS, `{"clients":[]}`)
  230. if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
  231. Client: model.Client{Email: "fresh@x", ID: "55555555-6666-7777-8888-999999999999", SubID: "sub-fresh", Enable: true},
  232. InboundIds: []int{healthy.Id, broken.Id},
  233. LimitHwid: 5,
  234. }); err == nil {
  235. t.Fatal("creating over an unparsable inbound returned no error")
  236. }
  237. if rec := lookupClientRecord(t, "fresh@x"); rec.LimitHwid != 0 {
  238. t.Fatalf("limit_hwid = %d, want 0 on a create that failed", rec.LimitHwid)
  239. }
  240. }
  241. func assertNamesFailedInbounds(t *testing.T, err error, broken []*model.Inbound, healthy *model.Inbound) {
  242. t.Helper()
  243. if err == nil {
  244. t.Fatalf("applying %d unparsable inbounds returned no error", len(broken))
  245. }
  246. for _, ib := range broken {
  247. if want := fmt.Sprintf("inbound %d:", ib.Id); !strings.Contains(err.Error(), want) {
  248. t.Fatalf("error %q does not name the failing %s", err, want)
  249. }
  250. }
  251. if blamed := fmt.Sprintf("inbound %d:", healthy.Id); strings.Contains(err.Error(), blamed) {
  252. t.Fatalf("error %q blames the healthy %s", err, blamed)
  253. }
  254. }
  255. // TestFanoutReportsEveryFailingInbound pins that no inbound aborts the others:
  256. // each failure names its own inbound, and the healthy ones still get the client.
  257. func TestFanoutReportsEveryFailingInbound(t *testing.T) {
  258. const halfBadUUID = "22222222-3333-4444-5555-666666666666"
  259. t.Run("create", func(t *testing.T) {
  260. setupBulkDB(t)
  261. startSerializedWriter(t)
  262. svc := &ClientService{}
  263. inboundSvc := &InboundService{}
  264. broken := []*model.Inbound{
  265. mkInbound(t, 41201, model.VLESS, `{"clients":`),
  266. mkInbound(t, 41202, model.VLESS, `{"clients":`),
  267. }
  268. healthy := mkInbound(t, 41203, model.VLESS, `{"clients":[]}`)
  269. _, err := svc.Create(inboundSvc, &ClientCreatePayload{
  270. Client: model.Client{Email: "halfbad@x", ID: halfBadUUID, SubID: "sub-halfbad", Enable: true},
  271. InboundIds: []int{broken[0].Id, broken[1].Id, healthy.Id},
  272. })
  273. assertNamesFailedInbounds(t, err, broken, healthy)
  274. if !settingsHoldUUID(t, inboundSvc, healthy.Id, halfBadUUID) {
  275. t.Fatalf("healthy inbound %d did not get the client", healthy.Id)
  276. }
  277. })
  278. t.Run("attach", func(t *testing.T) {
  279. setupBulkDB(t)
  280. startSerializedWriter(t)
  281. svc := &ClientService{}
  282. inboundSvc := &InboundService{}
  283. seed := mkInbound(t, 41301, model.VLESS, `{"clients":[]}`)
  284. if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
  285. Client: model.Client{Email: "halfbad@x", ID: halfBadUUID, SubID: "sub-halfbad", Enable: true},
  286. InboundIds: []int{seed.Id},
  287. }); err != nil {
  288. t.Fatalf("seed Create: %v", err)
  289. }
  290. broken := []*model.Inbound{
  291. mkInbound(t, 41302, model.VLESS, `{"clients":`),
  292. mkInbound(t, 41303, model.VLESS, `{"clients":`),
  293. }
  294. healthy := mkInbound(t, 41304, model.VLESS, `{"clients":[]}`)
  295. rec := lookupClientRecord(t, "halfbad@x")
  296. _, err := svc.Attach(inboundSvc, rec.Id, []int{broken[0].Id, broken[1].Id, healthy.Id})
  297. assertNamesFailedInbounds(t, err, broken, healthy)
  298. if !settingsHoldUUID(t, inboundSvc, healthy.Id, halfBadUUID) {
  299. t.Fatalf("healthy inbound %d did not get the client", healthy.Id)
  300. }
  301. })
  302. }