inbound_update_hot_users_test.go 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209
  1. package service
  2. import (
  3. "context"
  4. "errors"
  5. "slices"
  6. "strings"
  7. "sync"
  8. "testing"
  9. "github.com/mhsanaei/3x-ui/v3/internal/database"
  10. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  11. )
  12. // inboundOpsProbe records every handler and user operation the panel sends the
  13. // core, in order, and fails a remove or the first add the way xray does.
  14. type inboundOpsProbe struct {
  15. fakeNodeRuntime
  16. mu sync.Mutex
  17. ops []string
  18. removeErr error
  19. addErr error
  20. }
  21. func (p *inboundOpsProbe) record(op string) {
  22. p.mu.Lock()
  23. defer p.mu.Unlock()
  24. p.ops = append(p.ops, op)
  25. }
  26. func (p *inboundOpsProbe) AddInbound(context.Context, *model.Inbound) error {
  27. p.record("add-inbound")
  28. return nil
  29. }
  30. func (p *inboundOpsProbe) DelInbound(context.Context, *model.Inbound) error {
  31. p.record("del-inbound")
  32. return nil
  33. }
  34. func (p *inboundOpsProbe) UpdateInbound(context.Context, *model.Inbound, *model.Inbound) error {
  35. p.record("update-inbound")
  36. return nil
  37. }
  38. func (p *inboundOpsProbe) AddUser(_ context.Context, _ *model.Inbound, user map[string]any) error {
  39. email, _ := user["email"].(string)
  40. p.record("add-user " + email)
  41. p.mu.Lock()
  42. defer p.mu.Unlock()
  43. err := p.addErr
  44. p.addErr = nil
  45. return err
  46. }
  47. func (p *inboundOpsProbe) RemoveUser(_ context.Context, _ *model.Inbound, email string) error {
  48. p.record("remove-user " + email)
  49. return p.removeErr
  50. }
  51. func (p *inboundOpsProbe) recorded() []string {
  52. p.mu.Lock()
  53. defer p.mu.Unlock()
  54. return append([]string(nil), p.ops...)
  55. }
  56. func hotUsersClient(email, id string, enable bool) model.Client {
  57. return model.Client{Email: email, ID: id, Enable: enable, CreatedAt: 1, UpdatedAt: 1}
  58. }
  59. // A master pushes the whole inbound for every client it disables or renews; the
  60. // node used to delete and re-add the handler, dropping every live connection.
  61. func TestNodeInboundPushChangingOnlyClientsKeepsTheHandler(t *testing.T) {
  62. const (
  63. idA = "5f2eb9d6-3a2f-4a55-9812-6ea1e2f7a301"
  64. idB = "5f2eb9d6-3a2f-4a55-9812-6ea1e2f7a302"
  65. )
  66. active := []model.Client{hotUsersClient("a", idA, true), hotUsersClient("b", idB, true)}
  67. bDisabled := []model.Client{hotUsersClient("a", idA, true), hotUsersClient("b", idB, false)}
  68. cases := []struct {
  69. name string
  70. stored, pushed []model.Client
  71. portShift int
  72. enabling bool
  73. removeErr error
  74. addErr error
  75. wantOps []string
  76. }{
  77. {
  78. name: "disabling one client removes only that user", stored: active, pushed: bDisabled,
  79. wantOps: []string{"remove-user b"},
  80. },
  81. {
  82. name: "renewing a disabled client adds only that user", stored: bDisabled, pushed: active,
  83. wantOps: []string{"add-user b"},
  84. },
  85. {
  86. name: "a port change still rebuilds the handler", stored: active, pushed: active, portShift: 1,
  87. wantOps: []string{"del-inbound", "add-inbound"},
  88. },
  89. {
  90. name: "enabling a disabled inbound adds its handler", stored: active, pushed: active, enabling: true,
  91. wantOps: []string{"del-inbound", "add-inbound"},
  92. },
  93. {
  94. name: "a user the core no longer holds is not a rebuild", stored: active, pushed: bDisabled,
  95. removeErr: errors.New("failed to remove user: User b not found."),
  96. wantOps: []string{"remove-user b"},
  97. },
  98. {
  99. name: "a handler the core lost is rebuilt", stored: active, pushed: bDisabled,
  100. removeErr: errors.New("failed to remove user: failed to get handler: hot-users > handler not found: hot-users"),
  101. wantOps: []string{"remove-user b", "del-inbound", "add-inbound"},
  102. },
  103. {
  104. name: "a user the core already holds is replaced", stored: bDisabled, pushed: active,
  105. addErr: errors.New("User b already exists."),
  106. wantOps: []string{"add-user b", "remove-user b", "add-user b"},
  107. },
  108. }
  109. for _, tc := range cases {
  110. t.Run(tc.name, func(t *testing.T) {
  111. setupConflictDB(t)
  112. // A node keeps this default; the master's own setting decides node restarts.
  113. setRestartOnClientDisable(t, true)
  114. probe := &inboundOpsProbe{removeErr: tc.removeErr, addErr: tc.addErr}
  115. useTestRuntimeManager(t).SetLocalRuntimeOverride(probe)
  116. seedInboundConflict(t, "hot-users", "0.0.0.0", 50081, model.VLESS, `{"network":"tcp"}`, clientsSettings(t, tc.stored))
  117. stored := loadInboundByTag(t, "hot-users")
  118. if err := (&ClientService{}).SyncInbound(nil, stored.Id, tc.stored); err != nil {
  119. t.Fatalf("SyncInbound: %v", err)
  120. }
  121. if tc.enabling {
  122. if err := database.GetDB().Model(stored).Update("enable", false).Error; err != nil {
  123. t.Fatalf("disable stored inbound: %v", err)
  124. }
  125. }
  126. pushed := *stored
  127. pushed.Settings = clientsSettings(t, tc.pushed)
  128. pushed.Port += tc.portShift
  129. pushed.Enable = true
  130. _, needRestart, err := (&InboundService{FromNodeSync: true}).UpdateInbound(&pushed)
  131. if err != nil {
  132. t.Fatalf("UpdateInbound: %v", err)
  133. }
  134. if got := probe.recorded(); !slices.Equal(got, tc.wantOps) {
  135. t.Fatalf("core operations = %q, want %q", got, tc.wantOps)
  136. }
  137. if needRestart {
  138. t.Fatal("needRestart = true, want the push applied without restarting the core")
  139. }
  140. })
  141. }
  142. }
  143. // AmneziaWG has its own in-place reconfigure; deleting and re-adding the inbound
  144. // rebuilt the device and dropped every peer's session on each pushed client change.
  145. func TestNodeAmneziaWGPushReconfiguresTheDeviceInPlace(t *testing.T) {
  146. setupConflictDB(t)
  147. probe := &inboundOpsProbe{}
  148. useTestRuntimeManager(t).SetLocalRuntimeOverride(probe)
  149. settings := awgRelayWindowSettings(t, "hot-awg")
  150. seedInboundConflict(t, "hot-awg", "0.0.0.0", 51830, model.AmneziaWG, "", settings)
  151. stored := loadInboundByTag(t, "hot-awg")
  152. clients, err := (&InboundService{}).GetClients(stored)
  153. if err != nil {
  154. t.Fatalf("GetClients: %v", err)
  155. }
  156. if err := (&ClientService{}).SyncInbound(nil, stored.Id, clients); err != nil {
  157. t.Fatalf("SyncInbound: %v", err)
  158. }
  159. pushed := *stored
  160. pushed.Settings = strings.Replace(settings, `"enable":true`, `"enable":false`, 1)
  161. if _, _, err := (&InboundService{FromNodeSync: true}).UpdateInbound(&pushed); err != nil {
  162. t.Fatalf("UpdateInbound: %v", err)
  163. }
  164. if got, want := probe.recorded(), []string{"update-inbound"}; !slices.Equal(got, want) {
  165. t.Fatalf("runtime operations = %q, want %q", got, want)
  166. }
  167. }
  168. // A master re-sends every inbound after it restarts; an unchanged one must not
  169. // touch the core, even for a protocol whose users have no per-user API path.
  170. func TestNodeInboundUnchangedPushLeavesTheCoreAlone(t *testing.T) {
  171. setupConflictDB(t)
  172. probe := &inboundOpsProbe{}
  173. useTestRuntimeManager(t).SetLocalRuntimeOverride(probe)
  174. clients := []model.Client{{Email: "ss-a", Password: "a-pass", Enable: true, CreatedAt: 1, UpdatedAt: 1}}
  175. settings := `{"method":"chacha20-ietf-poly1305","network":"tcp,udp","clients":[{"method":"chacha20-ietf-poly1305","password":"a-pass","email":"ss-a","enable":true,"created_at":1,"updated_at":1}]}`
  176. seedInboundConflict(t, "hot-ss", "0.0.0.0", 50082, model.Shadowsocks, `{"network":"tcp"}`, settings)
  177. stored := loadInboundByTag(t, "hot-ss")
  178. if err := (&ClientService{}).SyncInbound(nil, stored.Id, clients); err != nil {
  179. t.Fatalf("SyncInbound: %v", err)
  180. }
  181. pushed := *stored
  182. if _, _, err := (&InboundService{FromNodeSync: true}).UpdateInbound(&pushed); err != nil {
  183. t.Fatalf("UpdateInbound: %v", err)
  184. }
  185. if got := probe.recorded(); len(got) != 0 {
  186. t.Fatalf("core operations = %q, want none for an unchanged inbound", got)
  187. }
  188. }