inbound_update_hot_users_test.go 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "slices"
  7. "strings"
  8. "sync"
  9. "testing"
  10. "github.com/mhsanaei/3x-ui/v3/internal/database"
  11. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  12. )
  13. // inboundOpsProbe records every handler and user operation the panel sends the
  14. // core, in order, and fails a remove or the first add the way xray does.
  15. type inboundOpsProbe struct {
  16. fakeNodeRuntime
  17. mu sync.Mutex
  18. ops []string
  19. removeErr error
  20. addErr error
  21. }
  22. func (p *inboundOpsProbe) record(op string) {
  23. p.mu.Lock()
  24. defer p.mu.Unlock()
  25. p.ops = append(p.ops, op)
  26. }
  27. func (p *inboundOpsProbe) AddInbound(context.Context, *model.Inbound) error {
  28. p.record("add-inbound")
  29. return nil
  30. }
  31. func (p *inboundOpsProbe) DelInbound(context.Context, *model.Inbound) error {
  32. p.record("del-inbound")
  33. return nil
  34. }
  35. func (p *inboundOpsProbe) UpdateInbound(context.Context, *model.Inbound, *model.Inbound) error {
  36. p.record("update-inbound")
  37. return nil
  38. }
  39. func (p *inboundOpsProbe) AddUser(_ context.Context, _ *model.Inbound, user map[string]any) error {
  40. email, _ := user["email"].(string)
  41. p.record("add-user " + email)
  42. p.mu.Lock()
  43. defer p.mu.Unlock()
  44. err := p.addErr
  45. p.addErr = nil
  46. return err
  47. }
  48. func (p *inboundOpsProbe) RemoveUser(_ context.Context, _ *model.Inbound, email string) error {
  49. p.record("remove-user " + email)
  50. return p.removeErr
  51. }
  52. func (p *inboundOpsProbe) recorded() []string {
  53. p.mu.Lock()
  54. defer p.mu.Unlock()
  55. return append([]string(nil), p.ops...)
  56. }
  57. func hotUsersClient(email, id string, enable bool) model.Client {
  58. return model.Client{Email: email, ID: id, Enable: enable, CreatedAt: 1, UpdatedAt: 1}
  59. }
  60. // A master pushes the whole inbound for every client it disables or renews; the
  61. // node used to delete and re-add the handler, dropping every live connection.
  62. func TestNodeInboundPushChangingOnlyClientsKeepsTheHandler(t *testing.T) {
  63. const (
  64. idA = "5f2eb9d6-3a2f-4a55-9812-6ea1e2f7a301"
  65. idB = "5f2eb9d6-3a2f-4a55-9812-6ea1e2f7a302"
  66. )
  67. active := []model.Client{hotUsersClient("a", idA, true), hotUsersClient("b", idB, true)}
  68. bDisabled := []model.Client{hotUsersClient("a", idA, true), hotUsersClient("b", idB, false)}
  69. cases := []struct {
  70. name string
  71. stored, pushed []model.Client
  72. portShift int
  73. enabling bool
  74. removeErr error
  75. addErr error
  76. wantOps []string
  77. }{
  78. {
  79. name: "disabling one client removes only that user", stored: active, pushed: bDisabled,
  80. wantOps: []string{"remove-user b"},
  81. },
  82. {
  83. name: "renewing a disabled client adds only that user", stored: bDisabled, pushed: active,
  84. wantOps: []string{"add-user b"},
  85. },
  86. {
  87. name: "a port change still rebuilds the handler", stored: active, pushed: active, portShift: 1,
  88. wantOps: []string{"del-inbound", "add-inbound"},
  89. },
  90. {
  91. name: "enabling a disabled inbound adds its handler", stored: active, pushed: active, enabling: true,
  92. wantOps: []string{"del-inbound", "add-inbound"},
  93. },
  94. {
  95. name: "a user the core no longer holds is not a rebuild", stored: active, pushed: bDisabled,
  96. removeErr: errors.New("failed to remove user: User b not found."),
  97. wantOps: []string{"remove-user b"},
  98. },
  99. {
  100. name: "a handler the core lost is rebuilt", stored: active, pushed: bDisabled,
  101. removeErr: errors.New("failed to remove user: failed to get handler: hot-users > handler not found: hot-users"),
  102. wantOps: []string{"remove-user b", "del-inbound", "add-inbound"},
  103. },
  104. {
  105. name: "a user the core already holds is replaced", stored: bDisabled, pushed: active,
  106. addErr: errors.New("User b already exists."),
  107. wantOps: []string{"add-user b", "remove-user b", "add-user b"},
  108. },
  109. }
  110. for _, tc := range cases {
  111. t.Run(tc.name, func(t *testing.T) {
  112. setupConflictDB(t)
  113. // A node keeps this default; the master's own setting decides node restarts.
  114. setRestartOnClientDisable(t, true)
  115. probe := &inboundOpsProbe{removeErr: tc.removeErr, addErr: tc.addErr}
  116. useTestRuntimeManager(t).SetLocalRuntimeOverride(probe)
  117. seedInboundConflict(t, "hot-users", "0.0.0.0", 50081, model.VLESS, `{"network":"tcp"}`, clientsSettings(t, tc.stored))
  118. stored := loadInboundByTag(t, "hot-users")
  119. if err := (&ClientService{}).SyncInbound(nil, stored.Id, tc.stored); err != nil {
  120. t.Fatalf("SyncInbound: %v", err)
  121. }
  122. if tc.enabling {
  123. if err := database.GetDB().Model(stored).Update("enable", false).Error; err != nil {
  124. t.Fatalf("disable stored inbound: %v", err)
  125. }
  126. }
  127. pushed := *stored
  128. pushed.Settings = clientsSettings(t, tc.pushed)
  129. pushed.Port += tc.portShift
  130. pushed.Enable = true
  131. _, needRestart, err := (&InboundService{FromNodeSync: true}).UpdateInbound(&pushed)
  132. if err != nil {
  133. t.Fatalf("UpdateInbound: %v", err)
  134. }
  135. if got := probe.recorded(); !slices.Equal(got, tc.wantOps) {
  136. t.Fatalf("core operations = %q, want %q", got, tc.wantOps)
  137. }
  138. if needRestart {
  139. t.Fatal("needRestart = true, want the push applied without restarting the core")
  140. }
  141. })
  142. }
  143. }
  144. // AmneziaWG has its own in-place reconfigure; deleting and re-adding the inbound
  145. // rebuilt the device and dropped every peer's session on each pushed client change.
  146. func TestNodeAmneziaWGPushReconfiguresTheDeviceInPlace(t *testing.T) {
  147. setupConflictDB(t)
  148. probe := &inboundOpsProbe{}
  149. useTestRuntimeManager(t).SetLocalRuntimeOverride(probe)
  150. settings := awgRelayWindowSettings(t, "hot-awg")
  151. seedInboundConflict(t, "hot-awg", "0.0.0.0", 51830, model.AmneziaWG, "", settings)
  152. stored := loadInboundByTag(t, "hot-awg")
  153. clients, err := (&InboundService{}).GetClients(stored)
  154. if err != nil {
  155. t.Fatalf("GetClients: %v", err)
  156. }
  157. if err := (&ClientService{}).SyncInbound(nil, stored.Id, clients); err != nil {
  158. t.Fatalf("SyncInbound: %v", err)
  159. }
  160. pushed := *stored
  161. pushed.Settings = strings.Replace(settings, `"enable":true`, `"enable":false`, 1)
  162. if _, _, err := (&InboundService{FromNodeSync: true}).UpdateInbound(&pushed); err != nil {
  163. t.Fatalf("UpdateInbound: %v", err)
  164. }
  165. if got, want := probe.recorded(), []string{"update-inbound"}; !slices.Equal(got, want) {
  166. t.Fatalf("runtime operations = %q, want %q", got, want)
  167. }
  168. }
  169. // A master re-sends every inbound after it restarts; an unchanged one must not
  170. // touch the core, even for a protocol whose users have no per-user API path.
  171. func TestNodeInboundUnchangedPushLeavesTheCoreAlone(t *testing.T) {
  172. setupConflictDB(t)
  173. probe := &inboundOpsProbe{}
  174. useTestRuntimeManager(t).SetLocalRuntimeOverride(probe)
  175. clients := []model.Client{{Email: "ss-a", Password: "a-pass", Enable: true, CreatedAt: 1, UpdatedAt: 1}}
  176. 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}]}`
  177. seedInboundConflict(t, "hot-ss", "0.0.0.0", 50082, model.Shadowsocks, `{"network":"tcp"}`, settings)
  178. stored := loadInboundByTag(t, "hot-ss")
  179. if err := (&ClientService{}).SyncInbound(nil, stored.Id, clients); err != nil {
  180. t.Fatalf("SyncInbound: %v", err)
  181. }
  182. pushed := *stored
  183. if _, _, err := (&InboundService{FromNodeSync: true}).UpdateInbound(&pushed); err != nil {
  184. t.Fatalf("UpdateInbound: %v", err)
  185. }
  186. if got := probe.recorded(); len(got) != 0 {
  187. t.Fatalf("core operations = %q, want none for an unchanged inbound", got)
  188. }
  189. }
  190. func ssHotSettings(t *testing.T, method, serverKey string, clients []model.Client) string {
  191. t.Helper()
  192. var settings map[string]any
  193. if err := json.Unmarshal([]byte(clientsSettings(t, clients)), &settings); err != nil {
  194. t.Fatalf("unmarshal settings: %v", err)
  195. }
  196. settings["method"] = method
  197. settings["network"] = "tcp,udp"
  198. if serverKey != "" {
  199. settings["password"] = serverKey
  200. } else {
  201. for _, c := range settings["clients"].([]any) {
  202. c.(map[string]any)["method"] = method
  203. }
  204. }
  205. out, err := json.Marshal(settings)
  206. if err != nil {
  207. t.Fatalf("marshal settings: %v", err)
  208. }
  209. return string(out)
  210. }
  211. // Multi-user shadowsocks has the same per-user API as vless, so a pushed client
  212. // change must not rebuild the handler and drop every connection on the port.
  213. func TestNodeShadowsocksPushChangingOnlyClientsKeepsTheHandler(t *testing.T) {
  214. ciphers := []struct{ name, method, serverKey, keyA, keyB string }{
  215. {"legacy aead", "aes-256-gcm", "", "a-pass", "b-pass"},
  216. {"2022", "2022-blake3-aes-128-gcm", "AAECAwQFBgcICQoLDA0ODw==", "EBESExQVFhcYGRobHB0eHw==", "ICEiIyQlJicoKSorLC0uLw=="},
  217. }
  218. for _, cipher := range ciphers {
  219. a := model.Client{Email: "ss-a", Password: cipher.keyA, Enable: true, CreatedAt: 1, UpdatedAt: 1}
  220. b := model.Client{Email: "ss-b", Password: cipher.keyB, Enable: true, CreatedAt: 1, UpdatedAt: 1}
  221. bOff := b
  222. bOff.Enable = false
  223. cases := []struct {
  224. name string
  225. stored, pushed []model.Client
  226. wantOps []string
  227. }{
  228. {"disabling one client removes only that user", []model.Client{a, b}, []model.Client{a, bOff}, []string{"remove-user ss-b"}},
  229. {"renewing a disabled client adds only that user", []model.Client{a, bOff}, []model.Client{a, b}, []string{"add-user ss-b"}},
  230. }
  231. for _, tc := range cases {
  232. t.Run(cipher.name+"/"+tc.name, func(t *testing.T) {
  233. setupConflictDB(t)
  234. setRestartOnClientDisable(t, true)
  235. probe := &inboundOpsProbe{}
  236. useTestRuntimeManager(t).SetLocalRuntimeOverride(probe)
  237. seedInboundConflict(t, "hot-ss", "0.0.0.0", 50083, model.Shadowsocks, `{"network":"tcp"}`,
  238. ssHotSettings(t, cipher.method, cipher.serverKey, tc.stored))
  239. stored := loadInboundByTag(t, "hot-ss")
  240. if err := (&ClientService{}).SyncInbound(nil, stored.Id, tc.stored); err != nil {
  241. t.Fatalf("SyncInbound: %v", err)
  242. }
  243. pushed := *stored
  244. pushed.Settings = ssHotSettings(t, cipher.method, cipher.serverKey, tc.pushed)
  245. _, needRestart, err := (&InboundService{FromNodeSync: true}).UpdateInbound(&pushed)
  246. if err != nil {
  247. t.Fatalf("UpdateInbound: %v", err)
  248. }
  249. if got := probe.recorded(); !slices.Equal(got, tc.wantOps) {
  250. t.Fatalf("core operations = %q, want %q", got, tc.wantOps)
  251. }
  252. if needRestart {
  253. t.Fatal("needRestart = true, want the push applied without restarting the core")
  254. }
  255. })
  256. }
  257. }
  258. }