inbound_node_reconcile_test.go 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "net/http"
  6. "net/http/httptest"
  7. "net/url"
  8. "sort"
  9. "strconv"
  10. "strings"
  11. "sync"
  12. "testing"
  13. "github.com/mhsanaei/3x-ui/v3/internal/database"
  14. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  15. "github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
  16. )
  17. // fakeNodePanel serves just enough of the node API for ReconcileNode: the
  18. // inbound list plus update/del endpoints, recording which remote ids get
  19. // deleted.
  20. func fakeNodePanel(t *testing.T, tagToID map[string]int) (*httptest.Server, func() []int) {
  21. t.Helper()
  22. var mu sync.Mutex
  23. var deleted []int
  24. writeOK := func(w http.ResponseWriter, obj any) {
  25. w.Header().Set("Content-Type", "application/json")
  26. _ = json.NewEncoder(w).Encode(map[string]any{"success": true, "msg": "", "obj": obj})
  27. }
  28. mux := http.NewServeMux()
  29. mux.HandleFunc("/panel/api/inbounds/list", func(w http.ResponseWriter, _ *http.Request) {
  30. type row struct {
  31. Id int `json:"id"`
  32. Tag string `json:"tag"`
  33. }
  34. rows := make([]row, 0, len(tagToID))
  35. for tag, id := range tagToID {
  36. rows = append(rows, row{Id: id, Tag: tag})
  37. }
  38. writeOK(w, rows)
  39. })
  40. mux.HandleFunc("/panel/api/inbounds/update/", func(w http.ResponseWriter, _ *http.Request) {
  41. writeOK(w, nil)
  42. })
  43. mux.HandleFunc("/panel/api/inbounds/del/", func(w http.ResponseWriter, r *http.Request) {
  44. id, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/panel/api/inbounds/del/"))
  45. if err != nil {
  46. http.Error(w, "bad id", http.StatusBadRequest)
  47. return
  48. }
  49. mu.Lock()
  50. deleted = append(deleted, id)
  51. mu.Unlock()
  52. writeOK(w, nil)
  53. })
  54. ts := httptest.NewServer(mux)
  55. t.Cleanup(ts.Close)
  56. return ts, func() []int {
  57. mu.Lock()
  58. defer mu.Unlock()
  59. out := append([]int(nil), deleted...)
  60. sort.Ints(out)
  61. return out
  62. }
  63. }
  64. func reconcileTestNode(t *testing.T, ts *httptest.Server, name, mode string, tags []string) *model.Node {
  65. t.Helper()
  66. u, err := url.Parse(ts.URL)
  67. if err != nil {
  68. t.Fatalf("parse test server URL: %v", err)
  69. }
  70. port, err := strconv.Atoi(u.Port())
  71. if err != nil {
  72. t.Fatalf("parse test server port: %v", err)
  73. }
  74. n := &model.Node{
  75. Name: name,
  76. Scheme: "http",
  77. Address: u.Hostname(),
  78. Port: port,
  79. BasePath: "/",
  80. ApiToken: "tok",
  81. Enable: true,
  82. AllowPrivateAddress: true,
  83. Status: "online",
  84. InboundSyncMode: mode,
  85. InboundTags: tags,
  86. }
  87. if err := database.GetDB().Create(n).Error; err != nil {
  88. t.Fatalf("create node: %v", err)
  89. }
  90. return n
  91. }
  92. // In "selected" sync mode the panel never imports the unselected inbounds, so
  93. // reconcile must not treat their absence from the local DB as a deletion: only
  94. // a *selected* tag missing locally may be swept from the node.
  95. func TestReconcileNode_SelectedModeLeavesUnselectedRemoteInbounds(t *testing.T) {
  96. setupConflictDB(t)
  97. ts, deletedIDs := fakeNodePanel(t, map[string]int{
  98. "keep": 1,
  99. "selected-gone": 2,
  100. "unmanaged": 3,
  101. })
  102. node := reconcileTestNode(t, ts, "sel-node", "selected", []string{"keep", "selected-gone"})
  103. seedInboundConflictNode(t, "keep", "", 443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id)
  104. svc := InboundService{}
  105. if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(node), node); err != nil {
  106. t.Fatalf("ReconcileNode: %v", err)
  107. }
  108. got := deletedIDs()
  109. if len(got) != 1 || got[0] != 2 {
  110. t.Fatalf("deleted remote ids = %v, want [2] (unmanaged inbound 3 must survive)", got)
  111. }
  112. }
  113. // "all" mode keeps the original anti-entropy contract: every remote inbound
  114. // missing from the local DB is deleted on the node.
  115. func TestReconcileNode_AllModeDeletesUndesiredRemoteInbounds(t *testing.T) {
  116. setupConflictDB(t)
  117. ts, deletedIDs := fakeNodePanel(t, map[string]int{
  118. "keep": 1,
  119. "gone-a": 2,
  120. "gone-b": 3,
  121. })
  122. node := reconcileTestNode(t, ts, "all-node", "all", nil)
  123. seedInboundConflictNode(t, "keep", "", 443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id)
  124. svc := InboundService{}
  125. if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(node), node); err != nil {
  126. t.Fatalf("ReconcileNode: %v", err)
  127. }
  128. got := deletedIDs()
  129. if len(got) != 2 || got[0] != 2 || got[1] != 3 {
  130. t.Fatalf("deleted remote ids = %v, want [2 3]", got)
  131. }
  132. }
  133. func TestEnsureInboundTagAllowed(t *testing.T) {
  134. setupConflictDB(t)
  135. db := database.GetDB()
  136. svc := NodeService{}
  137. selected := &model.Node{
  138. Name: "ensure-sel", Address: "127.0.0.1", Port: 2096, ApiToken: "tok",
  139. InboundSyncMode: "selected", InboundTags: []string{"a"},
  140. }
  141. if err := db.Create(selected).Error; err != nil {
  142. t.Fatalf("create node: %v", err)
  143. }
  144. if err := svc.EnsureInboundTagAllowed(selected.Id, "b"); err != nil {
  145. t.Fatalf("EnsureInboundTagAllowed add: %v", err)
  146. }
  147. var got model.Node
  148. if err := db.First(&got, selected.Id).Error; err != nil {
  149. t.Fatalf("reload node: %v", err)
  150. }
  151. if len(got.InboundTags) != 2 || got.InboundTags[0] != "a" || got.InboundTags[1] != "b" {
  152. t.Fatalf("InboundTags = %#v, want [a b]", got.InboundTags)
  153. }
  154. if err := svc.EnsureInboundTagAllowed(selected.Id, "a"); err != nil {
  155. t.Fatalf("EnsureInboundTagAllowed existing: %v", err)
  156. }
  157. if err := db.First(&got, selected.Id).Error; err != nil {
  158. t.Fatalf("reload node: %v", err)
  159. }
  160. if len(got.InboundTags) != 2 {
  161. t.Fatalf("existing tag must not duplicate, got %#v", got.InboundTags)
  162. }
  163. all := &model.Node{
  164. Name: "ensure-all", Address: "127.0.0.1", Port: 2097, ApiToken: "tok",
  165. InboundSyncMode: "all",
  166. }
  167. if err := db.Create(all).Error; err != nil {
  168. t.Fatalf("create node: %v", err)
  169. }
  170. if err := svc.EnsureInboundTagAllowed(all.Id, "x"); err != nil {
  171. t.Fatalf("EnsureInboundTagAllowed all-mode: %v", err)
  172. }
  173. var gotAll model.Node
  174. if err := db.First(&gotAll, all.Id).Error; err != nil {
  175. t.Fatalf("reload node: %v", err)
  176. }
  177. if len(gotAll.InboundTags) != 0 {
  178. t.Fatalf("all-mode node must stay without tags, got %#v", gotAll.InboundTags)
  179. }
  180. }