1
0

node_dirty_test.go 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  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. // An online, enabled node that is merely config-dirty must NOT be reported as
  99. // pending: every node-backed edit marks the node dirty as the reconcile
  100. // self-heal marker, so keying the "saved, node offline, will sync" toast off
  101. // the dirty flag fired it on every save to a healthy online node.
  102. func TestIsNodePending_OnlineDirtyNodeIsNotPending(t *testing.T) {
  103. setupConflictDB(t)
  104. db := database.GetDB()
  105. node := &model.Node{Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, Status: "online"}
  106. if err := db.Create(node).Error; err != nil {
  107. t.Fatalf("create node: %v", err)
  108. }
  109. nodeSvc := NodeService{}
  110. if nodeSvc.IsNodePending(node.Id) {
  111. t.Fatal("a clean online node must not be pending")
  112. }
  113. if err := nodeSvc.MarkNodeDirty(node.Id); err != nil {
  114. t.Fatalf("MarkNodeDirty: %v", err)
  115. }
  116. if nodeSvc.IsNodePending(node.Id) {
  117. t.Fatal("an online, enabled node must not be pending just because it is config-dirty")
  118. }
  119. }
  120. // Offline or disabled nodes are genuinely deferred and must report pending so
  121. // the "saved, node offline, will sync" toast still surfaces for them.
  122. func TestIsNodePending_OfflineOrDisabledIsPending(t *testing.T) {
  123. setupConflictDB(t)
  124. db := database.GetDB()
  125. offline := &model.Node{Name: "off", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, Status: "offline"}
  126. disabled := &model.Node{Name: "dis", Address: "127.0.0.1", Port: 2097, ApiToken: "tok", Enable: false, Status: "online"}
  127. for _, n := range []*model.Node{offline, disabled} {
  128. if err := db.Create(n).Error; err != nil {
  129. t.Fatalf("create node %s: %v", n.Name, err)
  130. }
  131. }
  132. // Node.Enable carries gorm default:true, so Create({Enable:false}) persists
  133. // TRUE — force the column off to actually exercise the disabled path.
  134. if err := db.Model(&model.Node{}).Where("id = ?", disabled.Id).Update("enable", false).Error; err != nil {
  135. t.Fatalf("force-disable node: %v", err)
  136. }
  137. nodeSvc := NodeService{}
  138. if !nodeSvc.IsNodePending(offline.Id) {
  139. t.Fatal("an offline node must be pending")
  140. }
  141. if !nodeSvc.IsNodePending(disabled.Id) {
  142. t.Fatal("a disabled node must be pending")
  143. }
  144. }
  145. // ClearNodeDirty must be a compare-and-swap on config_dirty_at so a concurrent
  146. // edit that re-dirties the node during a reconcile is not silently cleared.
  147. func TestNodeDirty_ClearIsCASOnDirtyAt(t *testing.T) {
  148. setupConflictDB(t)
  149. db := database.GetDB()
  150. node := &model.Node{Name: "n2", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, Status: "online"}
  151. if err := db.Create(node).Error; err != nil {
  152. t.Fatalf("create node: %v", err)
  153. }
  154. nodeSvc := NodeService{}
  155. if err := nodeSvc.MarkNodeDirty(node.Id); err != nil {
  156. t.Fatalf("MarkNodeDirty: %v", err)
  157. }
  158. _, _, dirty, dirtyAt, err := nodeSvc.NodeSyncState(node.Id)
  159. if err != nil {
  160. t.Fatalf("NodeSyncState: %v", err)
  161. }
  162. if !dirty {
  163. t.Fatal("node should be dirty after MarkNodeDirty")
  164. }
  165. if err := nodeSvc.ClearNodeDirty(node.Id, dirtyAt-1); err != nil {
  166. t.Fatalf("ClearNodeDirty stale token: %v", err)
  167. }
  168. if _, _, stillDirty, _, _ := nodeSvc.NodeSyncState(node.Id); !stillDirty {
  169. t.Fatal("stale-token clear must not clear the dirty flag")
  170. }
  171. if err := nodeSvc.ClearNodeDirty(node.Id, dirtyAt); err != nil {
  172. t.Fatalf("ClearNodeDirty matching token: %v", err)
  173. }
  174. if _, _, stillDirty, _, _ := nodeSvc.NodeSyncState(node.Id); stillDirty {
  175. t.Fatal("matching-token clear must clear the dirty flag")
  176. }
  177. }
  178. func TestMarkNodeDirtyTxRollsBackWithTransaction(t *testing.T) {
  179. setupConflictDB(t)
  180. db := database.GetDB()
  181. node := &model.Node{Name: "n3", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, Status: "online"}
  182. if err := db.Create(node).Error; err != nil {
  183. t.Fatalf("create node: %v", err)
  184. }
  185. nodeSvc := NodeService{}
  186. rollbackErr := errors.New("force rollback")
  187. if err := db.Transaction(func(tx *gorm.DB) error {
  188. if err := nodeSvc.MarkNodeDirtyTx(tx, node.Id); err != nil {
  189. return err
  190. }
  191. return rollbackErr
  192. }); !errors.Is(err, rollbackErr) {
  193. t.Fatalf("rollback tx: got %v want %v", err, rollbackErr)
  194. }
  195. if _, _, dirty, _, err := nodeSvc.NodeSyncState(node.Id); err != nil {
  196. t.Fatalf("NodeSyncState after rollback: %v", err)
  197. } else if dirty {
  198. t.Fatal("dirty flag escaped a rolled-back transaction")
  199. }
  200. if err := db.Transaction(func(tx *gorm.DB) error {
  201. return nodeSvc.MarkNodeDirtyTx(tx, node.Id)
  202. }); err != nil {
  203. t.Fatalf("commit tx: %v", err)
  204. }
  205. if _, _, dirty, _, err := nodeSvc.NodeSyncState(node.Id); err != nil {
  206. t.Fatalf("NodeSyncState after commit: %v", err)
  207. } else if !dirty {
  208. t.Fatal("dirty flag should commit with its transaction")
  209. }
  210. }
  211. // Editing a node must mark it config-dirty so the next traffic-sync tick
  212. // reconciles (pushes the panel's inbounds to the remote) before pulling a
  213. // snapshot. Without the dirty flag, re-pointing a node to a fresh server
  214. // makes the orphan sweep delete every central inbound absent from the empty
  215. // snapshot (#5461).
  216. func TestNodeService_UpdateMarksNodeDirty(t *testing.T) {
  217. setupConflictDB(t)
  218. db := database.GetDB()
  219. node := &model.Node{
  220. Name: "n1",
  221. Address: "10.0.0.1",
  222. Port: 2096,
  223. ApiToken: "tok",
  224. Enable: true,
  225. Status: "online",
  226. }
  227. if err := db.Create(node).Error; err != nil {
  228. t.Fatalf("create node: %v", err)
  229. }
  230. edited := &model.Node{
  231. Name: node.Name,
  232. Address: "10.0.0.2",
  233. Port: 2097,
  234. ApiToken: node.ApiToken,
  235. Enable: true,
  236. }
  237. nodeSvc := NodeService{}
  238. if err := nodeSvc.Update(node.Id, edited); err != nil {
  239. t.Fatalf("Update: %v", err)
  240. }
  241. _, _, dirty, _, err := nodeSvc.NodeSyncState(node.Id)
  242. if err != nil {
  243. t.Fatalf("NodeSyncState: %v", err)
  244. }
  245. if !dirty {
  246. t.Fatal("Update must mark the node config-dirty so sync reconciles before snapshot sweep (#5461)")
  247. }
  248. var got model.Node
  249. if err := db.First(&got, node.Id).Error; err != nil {
  250. t.Fatalf("reload node: %v", err)
  251. }
  252. if got.Address != "10.0.0.2" || got.Port != 2097 {
  253. t.Fatalf("node row not updated: address=%q port=%d", got.Address, got.Port)
  254. }
  255. }