inbound_node_reconcile_test.go 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  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. InboundsAdoptedAt: 1,
  87. }
  88. if err := database.GetDB().Create(n).Error; err != nil {
  89. t.Fatalf("create node: %v", err)
  90. }
  91. return n
  92. }
  93. // In "selected" sync mode the panel never imports the unselected inbounds, so
  94. // reconcile must not treat their absence from the local DB as a deletion: only
  95. // a *selected* tag missing locally may be swept from the node.
  96. func TestReconcileNode_SelectedModeLeavesUnselectedRemoteInbounds(t *testing.T) {
  97. setupConflictDB(t)
  98. ts, deletedIDs := fakeNodePanel(t, map[string]int{
  99. "keep": 1,
  100. "selected-gone": 2,
  101. "unmanaged": 3,
  102. })
  103. node := reconcileTestNode(t, ts, "sel-node", "selected", []string{"keep", "selected-gone"})
  104. seedInboundConflictNode(t, "keep", "", 443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id)
  105. svc := InboundService{}
  106. if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(node, nil), node); err != nil {
  107. t.Fatalf("ReconcileNode: %v", err)
  108. }
  109. got := deletedIDs()
  110. if len(got) != 1 || got[0] != 2 {
  111. t.Fatalf("deleted remote ids = %v, want [2] (unmanaged inbound 3 must survive)", got)
  112. }
  113. }
  114. // "all" mode keeps the original anti-entropy contract: every remote inbound
  115. // missing from the local DB is deleted on the node.
  116. func TestReconcileNode_AllModeDeletesUndesiredRemoteInbounds(t *testing.T) {
  117. setupConflictDB(t)
  118. ts, deletedIDs := fakeNodePanel(t, map[string]int{
  119. "keep": 1,
  120. "gone-a": 2,
  121. "gone-b": 3,
  122. })
  123. node := reconcileTestNode(t, ts, "all-node", "all", nil)
  124. seedInboundConflictNode(t, "keep", "", 443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id)
  125. svc := InboundService{}
  126. if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(node, nil), node); err != nil {
  127. t.Fatalf("ReconcileNode: %v", err)
  128. }
  129. got := deletedIDs()
  130. if len(got) != 2 || got[0] != 2 || got[1] != 3 {
  131. t.Fatalf("deleted remote ids = %v, want [2 3]", got)
  132. }
  133. }
  134. // A node whose pre-existing inbounds were never adopted into the central DB
  135. // has zero local rows for legitimate reasons: reconcile before that first
  136. // adoption must not sweep — it would delete every real inbound on the node
  137. // right after onboarding (add node, save it again, watch it get wiped).
  138. func TestReconcileNode_SkipsSweepBeforeFirstAdoption(t *testing.T) {
  139. setupConflictDB(t)
  140. ts, deletedIDs := fakeNodePanel(t, map[string]int{
  141. "real-a": 1,
  142. "real-b": 2,
  143. "real-c": 3,
  144. })
  145. node := reconcileTestNode(t, ts, "fresh-node", "all", nil)
  146. node.InboundsAdoptedAt = 0
  147. svc := InboundService{}
  148. if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(node, nil), node); err != nil {
  149. t.Fatalf("ReconcileNode: %v", err)
  150. }
  151. if got := deletedIDs(); len(got) != 0 {
  152. t.Fatalf("deleted remote ids = %v, want none before first adoption", got)
  153. }
  154. }
  155. // One inbound the node rejects (e.g. a legacy protocol failing the node's
  156. // request validation, #5685) must not abort the reconcile: the healthy inbound
  157. // is still pushed, the delete sweep still runs, and the returned error names
  158. // the failed tag so the caller keeps the dirty flag set for retry.
  159. func TestReconcileNode_ContinuesPastFailedInbound(t *testing.T) {
  160. setupConflictDB(t)
  161. var mu sync.Mutex
  162. updated := map[int]int{}
  163. var deleted []int
  164. tagToID := map[string]int{"legacy": 1, "healthy": 2, "gone": 3}
  165. writeOK := func(w http.ResponseWriter, obj any) {
  166. w.Header().Set("Content-Type", "application/json")
  167. _ = json.NewEncoder(w).Encode(map[string]any{"success": true, "msg": "", "obj": obj})
  168. }
  169. mux := http.NewServeMux()
  170. mux.HandleFunc("/panel/api/inbounds/list", func(w http.ResponseWriter, _ *http.Request) {
  171. type row struct {
  172. Id int `json:"id"`
  173. Tag string `json:"tag"`
  174. }
  175. rows := make([]row, 0, len(tagToID))
  176. for tag, id := range tagToID {
  177. rows = append(rows, row{Id: id, Tag: tag})
  178. }
  179. writeOK(w, rows)
  180. })
  181. mux.HandleFunc("/panel/api/inbounds/update/", func(w http.ResponseWriter, r *http.Request) {
  182. id, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/panel/api/inbounds/update/"))
  183. if err != nil {
  184. http.Error(w, "bad id", http.StatusBadRequest)
  185. return
  186. }
  187. if id == tagToID["legacy"] {
  188. http.Error(w, "request body failed validation", http.StatusBadRequest)
  189. return
  190. }
  191. mu.Lock()
  192. updated[id]++
  193. mu.Unlock()
  194. writeOK(w, nil)
  195. })
  196. mux.HandleFunc("/panel/api/inbounds/del/", func(w http.ResponseWriter, r *http.Request) {
  197. id, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/panel/api/inbounds/del/"))
  198. if err != nil {
  199. http.Error(w, "bad id", http.StatusBadRequest)
  200. return
  201. }
  202. mu.Lock()
  203. deleted = append(deleted, id)
  204. mu.Unlock()
  205. writeOK(w, nil)
  206. })
  207. ts := httptest.NewServer(mux)
  208. t.Cleanup(ts.Close)
  209. node := reconcileTestNode(t, ts, "half-broken-node", "all", nil)
  210. seedInboundConflictNode(t, "legacy", "", 1080, model.Protocol("socks"), ``, `{"auth":"noauth"}`, &node.Id)
  211. seedInboundConflictNode(t, "healthy", "", 443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id)
  212. svc := InboundService{}
  213. err := svc.ReconcileNode(context.Background(), runtime.NewRemote(node, nil), node)
  214. if err == nil {
  215. t.Fatal("ReconcileNode: want an error naming the rejected inbound, got nil")
  216. }
  217. if !strings.Contains(err.Error(), `reconcile inbound "legacy"`) {
  218. t.Fatalf("ReconcileNode error = %q, want it to name inbound \"legacy\"", err)
  219. }
  220. mu.Lock()
  221. healthyPushes := updated[tagToID["healthy"]]
  222. gotDeleted := append([]int(nil), deleted...)
  223. mu.Unlock()
  224. if healthyPushes != 1 {
  225. t.Fatalf("healthy inbound pushed %d times, want 1", healthyPushes)
  226. }
  227. sort.Ints(gotDeleted)
  228. if len(gotDeleted) != 1 || gotDeleted[0] != tagToID["gone"] {
  229. t.Fatalf("deleted remote ids = %v, want [%d] (sweep must still run past the failure)", gotDeleted, tagToID["gone"])
  230. }
  231. }
  232. func TestEnsureInboundTagAllowed(t *testing.T) {
  233. setupConflictDB(t)
  234. db := database.GetDB()
  235. svc := NodeService{}
  236. selected := &model.Node{
  237. Name: "ensure-sel", Address: "127.0.0.1", Port: 2096, ApiToken: "tok",
  238. InboundSyncMode: "selected", InboundTags: []string{"a"},
  239. }
  240. if err := db.Create(selected).Error; err != nil {
  241. t.Fatalf("create node: %v", err)
  242. }
  243. if err := svc.EnsureInboundTagAllowed(selected.Id, "b"); err != nil {
  244. t.Fatalf("EnsureInboundTagAllowed add: %v", err)
  245. }
  246. var got model.Node
  247. if err := db.First(&got, selected.Id).Error; err != nil {
  248. t.Fatalf("reload node: %v", err)
  249. }
  250. if len(got.InboundTags) != 2 || got.InboundTags[0] != "a" || got.InboundTags[1] != "b" {
  251. t.Fatalf("InboundTags = %#v, want [a b]", got.InboundTags)
  252. }
  253. if err := svc.EnsureInboundTagAllowed(selected.Id, "a"); err != nil {
  254. t.Fatalf("EnsureInboundTagAllowed existing: %v", err)
  255. }
  256. if err := db.First(&got, selected.Id).Error; err != nil {
  257. t.Fatalf("reload node: %v", err)
  258. }
  259. if len(got.InboundTags) != 2 {
  260. t.Fatalf("existing tag must not duplicate, got %#v", got.InboundTags)
  261. }
  262. all := &model.Node{
  263. Name: "ensure-all", Address: "127.0.0.1", Port: 2097, ApiToken: "tok",
  264. InboundSyncMode: "all",
  265. }
  266. if err := db.Create(all).Error; err != nil {
  267. t.Fatalf("create node: %v", err)
  268. }
  269. if err := svc.EnsureInboundTagAllowed(all.Id, "x"); err != nil {
  270. t.Fatalf("EnsureInboundTagAllowed all-mode: %v", err)
  271. }
  272. var gotAll model.Node
  273. if err := db.First(&gotAll, all.Id).Error; err != nil {
  274. t.Fatalf("reload node: %v", err)
  275. }
  276. if len(gotAll.InboundTags) != 0 {
  277. t.Fatalf("all-mode node must stay without tags, got %#v", gotAll.InboundTags)
  278. }
  279. }