node_dirty_test.go 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. package service
  2. import (
  3. "errors"
  4. "testing"
  5. "gorm.io/gorm"
  6. "github.com/mhsanaei/3x-ui/v3/internal/database"
  7. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  8. "github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
  9. )
  10. // While a node is config-dirty (a local edit committed before it could be
  11. // mirrored to the node), the traffic pull must not overwrite the central
  12. // inbound's config columns from the node's stale snapshot — only traffic
  13. // counters may advance. Otherwise a reconnecting node reverts the edit.
  14. func TestSetRemoteTraffic_DirtyPreservesConfig(t *testing.T) {
  15. setupConflictDB(t)
  16. db := database.GetDB()
  17. node := &model.Node{Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, Status: "online"}
  18. if err := db.Create(node).Error; err != nil {
  19. t.Fatalf("create node: %v", err)
  20. }
  21. id := node.Id
  22. const desiredSettings = `{"clients":[{"email":"a@x"}]}`
  23. central := &model.Inbound{
  24. UserId: 1,
  25. NodeID: &id,
  26. Tag: "in-443-tcp",
  27. Enable: true,
  28. Port: 443,
  29. Protocol: model.VLESS,
  30. Settings: desiredSettings,
  31. }
  32. if err := db.Create(central).Error; err != nil {
  33. t.Fatalf("create inbound: %v", err)
  34. }
  35. snap := &runtime.TrafficSnapshot{
  36. Inbounds: []*model.Inbound{{
  37. Tag: "in-443-tcp",
  38. Enable: true,
  39. Port: 443,
  40. Protocol: model.VLESS,
  41. Settings: `{"clients":[{"email":"b@x"}]}`,
  42. Up: 500,
  43. Down: 700,
  44. }},
  45. }
  46. svc := InboundService{}
  47. if _, err := svc.setRemoteTrafficLocked(id, snap, true); err != nil {
  48. t.Fatalf("setRemoteTrafficLocked dirty: %v", err)
  49. }
  50. var got model.Inbound
  51. if err := db.First(&got, central.Id).Error; err != nil {
  52. t.Fatalf("reload inbound: %v", err)
  53. }
  54. if got.Settings != desiredSettings {
  55. t.Fatalf("dirty pull overwrote settings: want %q got %q", desiredSettings, got.Settings)
  56. }
  57. if got.Up != 500 || got.Down != 700 {
  58. t.Fatalf("traffic counters not applied while dirty: up=%d down=%d", got.Up, got.Down)
  59. }
  60. }
  61. // Deleting a *disabled* client attached to a node inbound must still propagate
  62. // to the node. The node's own DB carries the (disabled) client, so the central
  63. // panel has to mark the node dirty (→ reconcile) instead of dropping the delete
  64. // and letting the next traffic snapshot resurrect the client. Regression for
  65. // the enable-flag gate that used to skip the node path entirely (#5352).
  66. func TestDelInboundClientByEmail_DisabledNodeClientMarksDirty(t *testing.T) {
  67. setupConflictDB(t)
  68. db := database.GetDB()
  69. // Offline node so nodePushPlan reports dirty without needing a live runtime.
  70. node := &model.Node{Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, Status: "offline"}
  71. if err := db.Create(node).Error; err != nil {
  72. t.Fatalf("create node: %v", err)
  73. }
  74. id := node.Id
  75. central := &model.Inbound{
  76. UserId: 1,
  77. NodeID: &id,
  78. Tag: "in-443-tcp",
  79. Enable: true,
  80. Port: 443,
  81. Protocol: model.VLESS,
  82. Settings: `{"clients":[{"email":"a@x","enable":false}]}`,
  83. }
  84. if err := db.Create(central).Error; err != nil {
  85. t.Fatalf("create inbound: %v", err)
  86. }
  87. inboundSvc := &InboundService{}
  88. clientSvc := &ClientService{}
  89. if _, err := clientSvc.DelInboundClientByEmail(inboundSvc, central.Id, "a@x", false); err != nil {
  90. t.Fatalf("DelInboundClientByEmail: %v", err)
  91. }
  92. if _, _, dirty, _, err := (&NodeService{}).NodeSyncState(id); err != nil {
  93. t.Fatalf("NodeSyncState: %v", err)
  94. } else if !dirty {
  95. t.Fatal("deleting a disabled node client must mark the node dirty (#5352)")
  96. }
  97. }
  98. // ClearNodeDirty must be a compare-and-swap on config_dirty_at so a concurrent
  99. // edit that re-dirties the node during a reconcile is not silently cleared.
  100. func TestNodeDirty_ClearIsCASOnDirtyAt(t *testing.T) {
  101. setupConflictDB(t)
  102. db := database.GetDB()
  103. node := &model.Node{Name: "n2", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, Status: "online"}
  104. if err := db.Create(node).Error; err != nil {
  105. t.Fatalf("create node: %v", err)
  106. }
  107. nodeSvc := NodeService{}
  108. if err := nodeSvc.MarkNodeDirty(node.Id); err != nil {
  109. t.Fatalf("MarkNodeDirty: %v", err)
  110. }
  111. _, _, dirty, dirtyAt, err := nodeSvc.NodeSyncState(node.Id)
  112. if err != nil {
  113. t.Fatalf("NodeSyncState: %v", err)
  114. }
  115. if !dirty {
  116. t.Fatal("node should be dirty after MarkNodeDirty")
  117. }
  118. if err := nodeSvc.ClearNodeDirty(node.Id, dirtyAt-1); err != nil {
  119. t.Fatalf("ClearNodeDirty stale token: %v", err)
  120. }
  121. if _, _, stillDirty, _, _ := nodeSvc.NodeSyncState(node.Id); !stillDirty {
  122. t.Fatal("stale-token clear must not clear the dirty flag")
  123. }
  124. if err := nodeSvc.ClearNodeDirty(node.Id, dirtyAt); err != nil {
  125. t.Fatalf("ClearNodeDirty matching token: %v", err)
  126. }
  127. if _, _, stillDirty, _, _ := nodeSvc.NodeSyncState(node.Id); stillDirty {
  128. t.Fatal("matching-token clear must clear the dirty flag")
  129. }
  130. }
  131. func TestMarkNodeDirtyTxRollsBackWithTransaction(t *testing.T) {
  132. setupConflictDB(t)
  133. db := database.GetDB()
  134. node := &model.Node{Name: "n3", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, Status: "online"}
  135. if err := db.Create(node).Error; err != nil {
  136. t.Fatalf("create node: %v", err)
  137. }
  138. nodeSvc := NodeService{}
  139. rollbackErr := errors.New("force rollback")
  140. if err := db.Transaction(func(tx *gorm.DB) error {
  141. if err := nodeSvc.MarkNodeDirtyTx(tx, node.Id); err != nil {
  142. return err
  143. }
  144. return rollbackErr
  145. }); !errors.Is(err, rollbackErr) {
  146. t.Fatalf("rollback tx: got %v want %v", err, rollbackErr)
  147. }
  148. if _, _, dirty, _, err := nodeSvc.NodeSyncState(node.Id); err != nil {
  149. t.Fatalf("NodeSyncState after rollback: %v", err)
  150. } else if dirty {
  151. t.Fatal("dirty flag escaped a rolled-back transaction")
  152. }
  153. if err := db.Transaction(func(tx *gorm.DB) error {
  154. return nodeSvc.MarkNodeDirtyTx(tx, node.Id)
  155. }); err != nil {
  156. t.Fatalf("commit tx: %v", err)
  157. }
  158. if _, _, dirty, _, err := nodeSvc.NodeSyncState(node.Id); err != nil {
  159. t.Fatalf("NodeSyncState after commit: %v", err)
  160. } else if !dirty {
  161. t.Fatal("dirty flag should commit with its transaction")
  162. }
  163. }
  164. // Editing a node must mark it config-dirty so the next traffic-sync tick
  165. // reconciles (pushes the panel's inbounds to the remote) before pulling a
  166. // snapshot. Without the dirty flag, re-pointing a node to a fresh server
  167. // makes the orphan sweep delete every central inbound absent from the empty
  168. // snapshot (#5461).
  169. func TestNodeService_UpdateMarksNodeDirty(t *testing.T) {
  170. setupConflictDB(t)
  171. db := database.GetDB()
  172. node := &model.Node{
  173. Name: "n1",
  174. Address: "10.0.0.1",
  175. Port: 2096,
  176. ApiToken: "tok",
  177. Enable: true,
  178. Status: "online",
  179. }
  180. if err := db.Create(node).Error; err != nil {
  181. t.Fatalf("create node: %v", err)
  182. }
  183. edited := &model.Node{
  184. Name: node.Name,
  185. Address: "10.0.0.2",
  186. Port: 2097,
  187. ApiToken: node.ApiToken,
  188. Enable: true,
  189. }
  190. nodeSvc := NodeService{}
  191. if err := nodeSvc.Update(node.Id, edited); err != nil {
  192. t.Fatalf("Update: %v", err)
  193. }
  194. _, _, dirty, _, err := nodeSvc.NodeSyncState(node.Id)
  195. if err != nil {
  196. t.Fatalf("NodeSyncState: %v", err)
  197. }
  198. if !dirty {
  199. t.Fatal("Update must mark the node config-dirty so sync reconciles before snapshot sweep (#5461)")
  200. }
  201. var got model.Node
  202. if err := db.First(&got, node.Id).Error; err != nil {
  203. t.Fatalf("reload node: %v", err)
  204. }
  205. if got.Address != "10.0.0.2" || got.Port != 2097 {
  206. t.Fatalf("node row not updated: address=%q port=%d", got.Address, got.Port)
  207. }
  208. }