1
0

node_dirty_test.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  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. func TestSetRemoteTraffic_MissingDisabledInboundIsNotSwept(t *testing.T) {
  62. setupConflictDB(t)
  63. db := database.GetDB()
  64. node := &model.Node{Name: "disabled-snapshot", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, Status: "online"}
  65. if err := db.Create(node).Error; err != nil {
  66. t.Fatal(err)
  67. }
  68. disabled := &model.Inbound{
  69. UserId: 1, NodeID: &node.Id, Tag: "disabled", Enable: false,
  70. Port: 24443, Protocol: model.VLESS, Settings: `{"clients":[]}`,
  71. }
  72. reported := &model.Inbound{
  73. UserId: 1, NodeID: &node.Id, Tag: "reported", Enable: true,
  74. Port: 24444, Protocol: model.VLESS, Settings: `{"clients":[]}`,
  75. }
  76. if err := db.Create(disabled).Error; err != nil {
  77. t.Fatal(err)
  78. }
  79. if err := db.Create(reported).Error; err != nil {
  80. t.Fatal(err)
  81. }
  82. snap := &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{{
  83. Tag: reported.Tag, Enable: true,
  84. Port: reported.Port, Protocol: reported.Protocol, Settings: reported.Settings,
  85. }}}
  86. if _, err := (&InboundService{}).setRemoteTrafficLocked(node.Id, snap, false); err != nil {
  87. t.Fatal(err)
  88. }
  89. var count int64
  90. if err := db.Model(&model.Inbound{}).Where("id=?", disabled.Id).Count(&count).Error; err != nil {
  91. t.Fatal(err)
  92. }
  93. if count != 1 {
  94. t.Fatalf("disabled inbound rows=%d, want 1", count)
  95. }
  96. }
  97. // Deleting a *disabled* client attached to a node inbound must still propagate
  98. // to the node. The node's own DB carries the (disabled) client, so the central
  99. // panel has to mark the node dirty (→ reconcile) instead of dropping the delete
  100. // and letting the next traffic snapshot resurrect the client. Regression for
  101. // the enable-flag gate that used to skip the node path entirely (#5352).
  102. func TestDelInboundClientByEmail_DisabledNodeClientMarksDirty(t *testing.T) {
  103. setupConflictDB(t)
  104. db := database.GetDB()
  105. // Offline node so nodePushPlan reports dirty without needing a live runtime.
  106. node := &model.Node{Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, Status: "offline"}
  107. if err := db.Create(node).Error; err != nil {
  108. t.Fatalf("create node: %v", err)
  109. }
  110. id := node.Id
  111. central := &model.Inbound{
  112. UserId: 1,
  113. NodeID: &id,
  114. Tag: "in-443-tcp",
  115. Enable: true,
  116. Port: 443,
  117. Protocol: model.VLESS,
  118. Settings: `{"clients":[{"email":"a@x","enable":false}]}`,
  119. }
  120. if err := db.Create(central).Error; err != nil {
  121. t.Fatalf("create inbound: %v", err)
  122. }
  123. inboundSvc := &InboundService{}
  124. clientSvc := &ClientService{}
  125. if _, err := clientSvc.DelInboundClientByEmail(inboundSvc, central.Id, "a@x", false, false); err != nil {
  126. t.Fatalf("DelInboundClientByEmail: %v", err)
  127. }
  128. if _, _, dirty, _, err := (&NodeService{}).NodeSyncState(id); err != nil {
  129. t.Fatalf("NodeSyncState: %v", err)
  130. } else if !dirty {
  131. t.Fatal("deleting a disabled node client must mark the node dirty (#5352)")
  132. }
  133. }
  134. // An online, enabled node that is merely config-dirty must NOT be reported as
  135. // pending: every node-backed edit marks the node dirty as the reconcile
  136. // self-heal marker, so keying the "saved, node offline, will sync" toast off
  137. // the dirty flag fired it on every save to a healthy online node.
  138. func TestIsNodePending_OnlineDirtyNodeIsNotPending(t *testing.T) {
  139. setupConflictDB(t)
  140. db := database.GetDB()
  141. node := &model.Node{Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, Status: "online"}
  142. if err := db.Create(node).Error; err != nil {
  143. t.Fatalf("create node: %v", err)
  144. }
  145. nodeSvc := NodeService{}
  146. if nodeSvc.IsNodePending(node.Id) {
  147. t.Fatal("a clean online node must not be pending")
  148. }
  149. if err := nodeSvc.MarkNodeDirty(node.Id); err != nil {
  150. t.Fatalf("MarkNodeDirty: %v", err)
  151. }
  152. if nodeSvc.IsNodePending(node.Id) {
  153. t.Fatal("an online, enabled node must not be pending just because it is config-dirty")
  154. }
  155. }
  156. // Offline or disabled nodes are genuinely deferred and must report pending so
  157. // the "saved, node offline, will sync" toast still surfaces for them.
  158. func TestIsNodePending_OfflineOrDisabledIsPending(t *testing.T) {
  159. setupConflictDB(t)
  160. db := database.GetDB()
  161. offline := &model.Node{Name: "off", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, Status: "offline"}
  162. disabled := &model.Node{Name: "dis", Address: "127.0.0.1", Port: 2097, ApiToken: "tok", Enable: false, Status: "online"}
  163. for _, n := range []*model.Node{offline, disabled} {
  164. if err := db.Create(n).Error; err != nil {
  165. t.Fatalf("create node %s: %v", n.Name, err)
  166. }
  167. }
  168. // Node.Enable carries gorm default:true, so Create({Enable:false}) persists
  169. // TRUE — force the column off to actually exercise the disabled path.
  170. if err := db.Model(&model.Node{}).Where("id = ?", disabled.Id).Update("enable", false).Error; err != nil {
  171. t.Fatalf("force-disable node: %v", err)
  172. }
  173. nodeSvc := NodeService{}
  174. if !nodeSvc.IsNodePending(offline.Id) {
  175. t.Fatal("an offline node must be pending")
  176. }
  177. if !nodeSvc.IsNodePending(disabled.Id) {
  178. t.Fatal("a disabled node must be pending")
  179. }
  180. }
  181. // ClearNodeDirty must be a compare-and-swap on config_dirty_at so a concurrent
  182. // edit that re-dirties the node during a reconcile is not silently cleared.
  183. func TestNodeDirty_ClearIsCASOnDirtyAt(t *testing.T) {
  184. setupConflictDB(t)
  185. db := database.GetDB()
  186. node := &model.Node{Name: "n2", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, Status: "online"}
  187. if err := db.Create(node).Error; err != nil {
  188. t.Fatalf("create node: %v", err)
  189. }
  190. nodeSvc := NodeService{}
  191. if err := nodeSvc.MarkNodeDirty(node.Id); err != nil {
  192. t.Fatalf("MarkNodeDirty: %v", err)
  193. }
  194. _, _, dirty, dirtyAt, err := nodeSvc.NodeSyncState(node.Id)
  195. if err != nil {
  196. t.Fatalf("NodeSyncState: %v", err)
  197. }
  198. if !dirty {
  199. t.Fatal("node should be dirty after MarkNodeDirty")
  200. }
  201. if err := nodeSvc.ClearNodeDirty(node.Id, dirtyAt-1); err != nil {
  202. t.Fatalf("ClearNodeDirty stale token: %v", err)
  203. }
  204. if _, _, stillDirty, _, _ := nodeSvc.NodeSyncState(node.Id); !stillDirty {
  205. t.Fatal("stale-token clear must not clear the dirty flag")
  206. }
  207. if err := nodeSvc.ClearNodeDirty(node.Id, dirtyAt); err != nil {
  208. t.Fatalf("ClearNodeDirty matching token: %v", err)
  209. }
  210. if _, _, stillDirty, _, _ := nodeSvc.NodeSyncState(node.Id); stillDirty {
  211. t.Fatal("matching-token clear must clear the dirty flag")
  212. }
  213. }
  214. func TestMarkNodeDirtyTxRollsBackWithTransaction(t *testing.T) {
  215. setupConflictDB(t)
  216. db := database.GetDB()
  217. node := &model.Node{Name: "n3", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, Status: "online"}
  218. if err := db.Create(node).Error; err != nil {
  219. t.Fatalf("create node: %v", err)
  220. }
  221. nodeSvc := NodeService{}
  222. rollbackErr := errors.New("force rollback")
  223. if err := db.Transaction(func(tx *gorm.DB) error {
  224. if err := nodeSvc.MarkNodeDirtyTx(tx, node.Id); err != nil {
  225. return err
  226. }
  227. return rollbackErr
  228. }); !errors.Is(err, rollbackErr) {
  229. t.Fatalf("rollback tx: got %v want %v", err, rollbackErr)
  230. }
  231. if _, _, dirty, _, err := nodeSvc.NodeSyncState(node.Id); err != nil {
  232. t.Fatalf("NodeSyncState after rollback: %v", err)
  233. } else if dirty {
  234. t.Fatal("dirty flag escaped a rolled-back transaction")
  235. }
  236. if err := db.Transaction(func(tx *gorm.DB) error {
  237. return nodeSvc.MarkNodeDirtyTx(tx, node.Id)
  238. }); err != nil {
  239. t.Fatalf("commit tx: %v", err)
  240. }
  241. if _, _, dirty, _, err := nodeSvc.NodeSyncState(node.Id); err != nil {
  242. t.Fatalf("NodeSyncState after commit: %v", err)
  243. } else if !dirty {
  244. t.Fatal("dirty flag should commit with its transaction")
  245. }
  246. }
  247. // Editing a node must mark it config-dirty so the next traffic-sync tick
  248. // reconciles (pushes the panel's inbounds to the remote) before pulling a
  249. // snapshot. Without the dirty flag, re-pointing a node to a fresh server
  250. // makes the orphan sweep delete every central inbound absent from the empty
  251. // snapshot (#5461).
  252. func TestNodeService_UpdateMarksNodeDirty(t *testing.T) {
  253. setupConflictDB(t)
  254. db := database.GetDB()
  255. node := &model.Node{
  256. Name: "n1",
  257. Address: "10.0.0.1",
  258. Port: 2096,
  259. ApiToken: "tok",
  260. Enable: true,
  261. Status: "online",
  262. }
  263. if err := db.Create(node).Error; err != nil {
  264. t.Fatalf("create node: %v", err)
  265. }
  266. edited := &model.Node{
  267. Name: node.Name,
  268. Address: "10.0.0.2",
  269. Port: 2097,
  270. ApiToken: node.ApiToken,
  271. Enable: true,
  272. }
  273. nodeSvc := NodeService{}
  274. if err := nodeSvc.Update(node.Id, edited); err != nil {
  275. t.Fatalf("Update: %v", err)
  276. }
  277. _, _, dirty, _, err := nodeSvc.NodeSyncState(node.Id)
  278. if err != nil {
  279. t.Fatalf("NodeSyncState: %v", err)
  280. }
  281. if !dirty {
  282. t.Fatal("Update must mark the node config-dirty so sync reconciles before snapshot sweep (#5461)")
  283. }
  284. var got model.Node
  285. if err := db.First(&got, node.Id).Error; err != nil {
  286. t.Fatalf("reload node: %v", err)
  287. }
  288. if got.Address != "10.0.0.2" || got.Port != 2097 {
  289. t.Fatalf("node row not updated: address=%q port=%d", got.Address, got.Port)
  290. }
  291. }