inbound_node_reconcile_test.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "net/http"
  7. "net/http/httptest"
  8. "net/url"
  9. "slices"
  10. "sort"
  11. "strconv"
  12. "strings"
  13. "sync"
  14. "testing"
  15. "github.com/mhsanaei/3x-ui/v3/internal/database"
  16. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  17. "github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
  18. )
  19. // fakeNodePanel serves just enough of the node API for ReconcileNode: the
  20. // inbound list plus update/del endpoints, recording which remote ids get
  21. // deleted.
  22. func fakeNodePanel(t *testing.T, tagToID map[string]int) (*httptest.Server, func() []int) {
  23. t.Helper()
  24. var mu sync.Mutex
  25. var deleted []int
  26. writeOK := func(w http.ResponseWriter, obj any) {
  27. w.Header().Set("Content-Type", "application/json")
  28. _ = json.NewEncoder(w).Encode(map[string]any{"success": true, "msg": "", "obj": obj})
  29. }
  30. mux := http.NewServeMux()
  31. mux.HandleFunc("/panel/api/inbounds/list", func(w http.ResponseWriter, _ *http.Request) {
  32. type row struct {
  33. Id int `json:"id"`
  34. Tag string `json:"tag"`
  35. }
  36. rows := make([]row, 0, len(tagToID))
  37. for tag, id := range tagToID {
  38. rows = append(rows, row{Id: id, Tag: tag})
  39. }
  40. writeOK(w, rows)
  41. })
  42. mux.HandleFunc("/panel/api/inbounds/update/", func(w http.ResponseWriter, _ *http.Request) {
  43. writeOK(w, nil)
  44. })
  45. mux.HandleFunc("/panel/api/inbounds/del/", func(w http.ResponseWriter, r *http.Request) {
  46. id, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/panel/api/inbounds/del/"))
  47. if err != nil {
  48. http.Error(w, "bad id", http.StatusBadRequest)
  49. return
  50. }
  51. mu.Lock()
  52. deleted = append(deleted, id)
  53. mu.Unlock()
  54. writeOK(w, nil)
  55. })
  56. ts := httptest.NewServer(mux)
  57. t.Cleanup(ts.Close)
  58. return ts, func() []int {
  59. mu.Lock()
  60. defer mu.Unlock()
  61. out := append([]int(nil), deleted...)
  62. sort.Ints(out)
  63. return out
  64. }
  65. }
  66. func reconcileTestNode(t *testing.T, ts *httptest.Server, name, mode string, tags []string) *model.Node {
  67. t.Helper()
  68. u, err := url.Parse(ts.URL)
  69. if err != nil {
  70. t.Fatalf("parse test server URL: %v", err)
  71. }
  72. port, err := strconv.Atoi(u.Port())
  73. if err != nil {
  74. t.Fatalf("parse test server port: %v", err)
  75. }
  76. n := &model.Node{
  77. Name: name,
  78. Scheme: "http",
  79. Address: u.Hostname(),
  80. Port: port,
  81. BasePath: "/",
  82. ApiToken: "tok",
  83. Enable: true,
  84. AllowPrivateAddress: true,
  85. Status: "online",
  86. InboundSyncMode: mode,
  87. InboundTags: tags,
  88. InboundsAdoptedAt: 1,
  89. }
  90. if err := database.GetDB().Create(n).Error; err != nil {
  91. t.Fatalf("create node: %v", err)
  92. }
  93. return n
  94. }
  95. // In "selected" sync mode the panel never imports the unselected inbounds, so
  96. // reconcile must not treat their absence from the local DB as a deletion: only
  97. // a *selected* tag missing locally may be swept from the node.
  98. func TestReconcileNode_SelectedModeLeavesUnselectedRemoteInbounds(t *testing.T) {
  99. setupConflictDB(t)
  100. ts, deletedIDs := fakeNodePanel(t, map[string]int{
  101. "keep": 1,
  102. "selected-gone": 2,
  103. "unmanaged": 3,
  104. })
  105. node := reconcileTestNode(t, ts, "sel-node", "selected", []string{"keep", "selected-gone"})
  106. seedInboundConflictNode(t, "keep", "", 443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id)
  107. svc := InboundService{}
  108. if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(node, nil), node); err != nil {
  109. t.Fatalf("ReconcileNode: %v", err)
  110. }
  111. got := deletedIDs()
  112. if len(got) != 1 || got[0] != 2 {
  113. t.Fatalf("deleted remote ids = %v, want [2] (unmanaged inbound 3 must survive)", got)
  114. }
  115. }
  116. // "all" mode keeps the original anti-entropy contract: every remote inbound
  117. // missing from the local DB is deleted on the node.
  118. func TestReconcileNode_AllModeDeletesUndesiredRemoteInbounds(t *testing.T) {
  119. setupConflictDB(t)
  120. ts, deletedIDs := fakeNodePanel(t, map[string]int{
  121. "keep": 1,
  122. "gone-a": 2,
  123. "gone-b": 3,
  124. })
  125. node := reconcileTestNode(t, ts, "all-node", "all", nil)
  126. seedInboundConflictNode(t, "keep", "", 443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id)
  127. svc := InboundService{}
  128. if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(node, nil), node); err != nil {
  129. t.Fatalf("ReconcileNode: %v", err)
  130. }
  131. got := deletedIDs()
  132. if len(got) != 2 || got[0] != 2 || got[1] != 3 {
  133. t.Fatalf("deleted remote ids = %v, want [2 3]", got)
  134. }
  135. }
  136. // A node whose pre-existing inbounds were never adopted into the central DB
  137. // has zero local rows for legitimate reasons: reconcile before that first
  138. // adoption must not sweep — it would delete every real inbound on the node
  139. // right after onboarding (add node, save it again, watch it get wiped).
  140. func TestReconcileNode_SkipsSweepBeforeFirstAdoption(t *testing.T) {
  141. setupConflictDB(t)
  142. ts, deletedIDs := fakeNodePanel(t, map[string]int{
  143. "real-a": 1,
  144. "real-b": 2,
  145. "real-c": 3,
  146. })
  147. node := reconcileTestNode(t, ts, "fresh-node", "all", nil)
  148. node.InboundsAdoptedAt = 0
  149. svc := InboundService{}
  150. if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(node, nil), node); err != nil {
  151. t.Fatalf("ReconcileNode: %v", err)
  152. }
  153. if got := deletedIDs(); len(got) != 0 {
  154. t.Fatalf("deleted remote ids = %v, want none before first adoption", got)
  155. }
  156. }
  157. // One inbound the node rejects (e.g. a legacy protocol failing the node's
  158. // request validation, #5685) must not abort the reconcile: the healthy inbound
  159. // is still pushed, the delete sweep still runs, and the returned error names
  160. // the failed tag so the caller keeps the dirty flag set for retry.
  161. func TestReconcileNode_ContinuesPastFailedInbound(t *testing.T) {
  162. setupConflictDB(t)
  163. var mu sync.Mutex
  164. updated := map[int]int{}
  165. var deleted []int
  166. tagToID := map[string]int{"legacy": 1, "healthy": 2, "gone": 3}
  167. writeOK := func(w http.ResponseWriter, obj any) {
  168. w.Header().Set("Content-Type", "application/json")
  169. _ = json.NewEncoder(w).Encode(map[string]any{"success": true, "msg": "", "obj": obj})
  170. }
  171. mux := http.NewServeMux()
  172. mux.HandleFunc("/panel/api/inbounds/list", func(w http.ResponseWriter, _ *http.Request) {
  173. type row struct {
  174. Id int `json:"id"`
  175. Tag string `json:"tag"`
  176. }
  177. rows := make([]row, 0, len(tagToID))
  178. for tag, id := range tagToID {
  179. rows = append(rows, row{Id: id, Tag: tag})
  180. }
  181. writeOK(w, rows)
  182. })
  183. mux.HandleFunc("/panel/api/inbounds/update/", func(w http.ResponseWriter, r *http.Request) {
  184. id, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/panel/api/inbounds/update/"))
  185. if err != nil {
  186. http.Error(w, "bad id", http.StatusBadRequest)
  187. return
  188. }
  189. if id == tagToID["legacy"] {
  190. http.Error(w, "request body failed validation", http.StatusBadRequest)
  191. return
  192. }
  193. mu.Lock()
  194. updated[id]++
  195. mu.Unlock()
  196. writeOK(w, nil)
  197. })
  198. mux.HandleFunc("/panel/api/inbounds/del/", func(w http.ResponseWriter, r *http.Request) {
  199. id, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/panel/api/inbounds/del/"))
  200. if err != nil {
  201. http.Error(w, "bad id", http.StatusBadRequest)
  202. return
  203. }
  204. mu.Lock()
  205. deleted = append(deleted, id)
  206. mu.Unlock()
  207. writeOK(w, nil)
  208. })
  209. ts := httptest.NewServer(mux)
  210. t.Cleanup(ts.Close)
  211. node := reconcileTestNode(t, ts, "half-broken-node", "all", nil)
  212. seedInboundConflictNode(t, "legacy", "", 1080, model.Protocol("socks"), ``, `{"auth":"noauth"}`, &node.Id)
  213. seedInboundConflictNode(t, "healthy", "", 443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id)
  214. svc := InboundService{}
  215. err := svc.ReconcileNode(context.Background(), runtime.NewRemote(node, nil), node)
  216. if err == nil {
  217. t.Fatal("ReconcileNode: want an error naming the rejected inbound, got nil")
  218. }
  219. if !strings.Contains(err.Error(), `reconcile inbound "legacy"`) {
  220. t.Fatalf("ReconcileNode error = %q, want it to name inbound \"legacy\"", err)
  221. }
  222. mu.Lock()
  223. healthyPushes := updated[tagToID["healthy"]]
  224. gotDeleted := append([]int(nil), deleted...)
  225. mu.Unlock()
  226. if healthyPushes != 1 {
  227. t.Fatalf("healthy inbound pushed %d times, want 1", healthyPushes)
  228. }
  229. sort.Ints(gotDeleted)
  230. if len(gotDeleted) != 1 || gotDeleted[0] != tagToID["gone"] {
  231. t.Fatalf("deleted remote ids = %v, want [%d] (sweep must still run past the failure)", gotDeleted, tagToID["gone"])
  232. }
  233. }
  234. func TestReconcileNode_AdoptsCompatibleOriginInboundWithoutRemoteMutation(t *testing.T) {
  235. setupConflictDB(t)
  236. var mu sync.Mutex
  237. mutations := 0
  238. writeOK := func(w http.ResponseWriter, obj any) {
  239. w.Header().Set("Content-Type", "application/json")
  240. _ = json.NewEncoder(w).Encode(map[string]any{"success": true, "msg": "", "obj": obj})
  241. }
  242. mux := http.NewServeMux()
  243. mux.HandleFunc("/panel/api/inbounds/list", func(w http.ResponseWriter, _ *http.Request) {
  244. writeOK(w, []map[string]any{{"id": 41, "tag": "already-deployed", "listen": "", "port": 8443, "protocol": "vless"}})
  245. })
  246. mux.HandleFunc("/panel/api/inbounds/", func(w http.ResponseWriter, _ *http.Request) {
  247. mu.Lock()
  248. mutations++
  249. mu.Unlock()
  250. writeOK(w, nil)
  251. })
  252. ts := httptest.NewServer(mux)
  253. t.Cleanup(ts.Close)
  254. node := reconcileTestNode(t, ts, "adopt-node", "all", nil)
  255. node.Guid = "origin-guid"
  256. if err := database.GetDB().Model(node).Update("guid", node.Guid).Error; err != nil {
  257. t.Fatalf("update node guid: %v", err)
  258. }
  259. seedInboundConflictNode(t, "desired-name", "", 8443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id)
  260. if err := database.GetDB().Model(&model.Inbound{}).Where("tag = ?", "desired-name").Update("origin_node_guid", node.Guid).Error; err != nil {
  261. t.Fatalf("set origin guid: %v", err)
  262. }
  263. svc := InboundService{}
  264. rt := runtime.NewRemote(node, nil)
  265. if err := svc.ReconcileNode(context.Background(), rt, node); err != nil {
  266. t.Fatalf("first ReconcileNode: %v", err)
  267. }
  268. if err := svc.ReconcileNode(context.Background(), rt, node); err != nil {
  269. t.Fatalf("second ReconcileNode: %v", err)
  270. }
  271. mu.Lock()
  272. got := mutations
  273. mu.Unlock()
  274. if got != 0 {
  275. t.Fatalf("remote mutations = %d, want 0 while adopting compatible deployed inbound", got)
  276. }
  277. }
  278. func TestReconcileNode_AmbiguousCompatibleInboundsAreNotSwept(t *testing.T) {
  279. setupConflictDB(t)
  280. ts, deletedIDs := fakeNodePanel(t, map[string]int{"alias-a": 51, "alias-b": 52})
  281. node := reconcileTestNode(t, ts, "ambiguous-node", "all", nil)
  282. node.Guid = "origin-guid"
  283. if err := database.GetDB().Model(node).Update("guid", node.Guid).Error; err != nil {
  284. t.Fatalf("update node guid: %v", err)
  285. }
  286. seedInboundConflictNode(t, "desired-name", "", 0, model.Protocol(""), `{}`, `{"clients":[]}`, &node.Id)
  287. if err := database.GetDB().Model(&model.Inbound{}).Where("tag = ?", "desired-name").Update("origin_node_guid", node.Guid).Error; err != nil {
  288. t.Fatalf("set origin guid: %v", err)
  289. }
  290. err := (&InboundService{}).ReconcileNode(context.Background(), runtime.NewRemote(node, nil), node)
  291. if err == nil || !strings.Contains(err.Error(), "ambiguous compatible remote inbounds") {
  292. t.Fatalf("ReconcileNode error = %v, want ambiguity error", err)
  293. }
  294. if got := deletedIDs(); len(got) != 0 {
  295. t.Fatalf("deleted ambiguous candidates = %v, want none", got)
  296. }
  297. }
  298. func TestReconcileNode_IncompatiblePortOccupantRemainsLoud(t *testing.T) {
  299. setupConflictDB(t)
  300. writeOK := func(w http.ResponseWriter, obj any) {
  301. w.Header().Set("Content-Type", "application/json")
  302. _ = json.NewEncoder(w).Encode(map[string]any{"success": true, "msg": "", "obj": obj})
  303. }
  304. mux := http.NewServeMux()
  305. mux.HandleFunc("/panel/api/inbounds/list", func(w http.ResponseWriter, _ *http.Request) {
  306. writeOK(w, []map[string]any{{"id": 42, "tag": "port-owner", "listen": "", "port": 9443, "protocol": "trojan"}})
  307. })
  308. mux.HandleFunc("/panel/api/inbounds/add", func(w http.ResponseWriter, _ *http.Request) {
  309. _ = json.NewEncoder(w).Encode(map[string]any{"success": false, "msg": "port already occupied", "obj": nil})
  310. })
  311. ts := httptest.NewServer(mux)
  312. t.Cleanup(ts.Close)
  313. node := reconcileTestNode(t, ts, "drift-node", "all", nil)
  314. node.Guid = "origin-guid"
  315. seedInboundConflictNode(t, "desired-name", "", 9443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id)
  316. if err := database.GetDB().Model(&model.Inbound{}).Where("tag = ?", "desired-name").Update("origin_node_guid", node.Guid).Error; err != nil {
  317. t.Fatalf("set origin guid: %v", err)
  318. }
  319. err := (&InboundService{}).ReconcileNode(context.Background(), runtime.NewRemote(node, nil), node)
  320. if err == nil || !strings.Contains(err.Error(), "port already occupied") {
  321. t.Fatalf("ReconcileNode error = %v, want loud incompatible-port error", err)
  322. }
  323. }
  324. func TestEnsureInboundTagAllowed(t *testing.T) {
  325. setupConflictDB(t)
  326. db := database.GetDB()
  327. svc := NodeService{}
  328. selected := &model.Node{
  329. Name: "ensure-sel", Address: "127.0.0.1", Port: 2096, ApiToken: "tok",
  330. InboundSyncMode: "selected", InboundTags: []string{"a"},
  331. }
  332. if err := db.Create(selected).Error; err != nil {
  333. t.Fatalf("create node: %v", err)
  334. }
  335. if err := svc.EnsureInboundTagAllowed(selected.Id, "b"); err != nil {
  336. t.Fatalf("EnsureInboundTagAllowed add: %v", err)
  337. }
  338. var got model.Node
  339. if err := db.First(&got, selected.Id).Error; err != nil {
  340. t.Fatalf("reload node: %v", err)
  341. }
  342. if len(got.InboundTags) != 2 || got.InboundTags[0] != "a" || got.InboundTags[1] != "b" {
  343. t.Fatalf("InboundTags = %#v, want [a b]", got.InboundTags)
  344. }
  345. if err := svc.EnsureInboundTagAllowed(selected.Id, "a"); err != nil {
  346. t.Fatalf("EnsureInboundTagAllowed existing: %v", err)
  347. }
  348. if err := db.First(&got, selected.Id).Error; err != nil {
  349. t.Fatalf("reload node: %v", err)
  350. }
  351. if len(got.InboundTags) != 2 {
  352. t.Fatalf("existing tag must not duplicate, got %#v", got.InboundTags)
  353. }
  354. all := &model.Node{
  355. Name: "ensure-all", Address: "127.0.0.1", Port: 2097, ApiToken: "tok",
  356. InboundSyncMode: "all",
  357. }
  358. if err := db.Create(all).Error; err != nil {
  359. t.Fatalf("create node: %v", err)
  360. }
  361. if err := svc.EnsureInboundTagAllowed(all.Id, "x"); err != nil {
  362. t.Fatalf("EnsureInboundTagAllowed all-mode: %v", err)
  363. }
  364. var gotAll model.Node
  365. if err := db.First(&gotAll, all.Id).Error; err != nil {
  366. t.Fatalf("reload node: %v", err)
  367. }
  368. if len(gotAll.InboundTags) != 0 {
  369. t.Fatalf("all-mode node must stay without tags, got %#v", gotAll.InboundTags)
  370. }
  371. }
  372. // A panel-created node inbound is stored as "n<id>-tag" and pushed to the node
  373. // with the prefix stripped, so the sweep's selected set must match both forms.
  374. func TestReconcileNode_SelectedModeSweepsPrefixedSelectedTag(t *testing.T) {
  375. setupConflictDB(t)
  376. ts, deletedIDs := fakeNodePanel(t, map[string]int{
  377. "keep": 1,
  378. "selected-gone": 2,
  379. "unmanaged": 3,
  380. })
  381. node := reconcileTestNode(t, ts, "sel-prefix-node", "selected", nil)
  382. prefix := fmt.Sprintf("n%d-", node.Id)
  383. node.InboundTags = []string{prefix + "keep", prefix + "selected-gone"}
  384. seedInboundConflictNode(t, prefix+"keep", "", 443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id)
  385. svc := InboundService{}
  386. if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(node, nil), node); err != nil {
  387. t.Fatalf("ReconcileNode: %v", err)
  388. }
  389. got := deletedIDs()
  390. if len(got) != 1 || got[0] != 2 {
  391. t.Fatalf("deleted remote ids = %v, want [2] (prefixed selected tag must be swept, unmanaged 3 must survive)", got)
  392. }
  393. }
  394. // Saving the node form marks the node dirty in the same transaction that grows
  395. // its managed set, so reconcile would sweep a tag the panel has not imported yet.
  396. func TestReconcileNode_SaveGrowingSelectionRearmsSweepGuard(t *testing.T) {
  397. cases := []struct {
  398. name string
  399. storedTags []string
  400. mode string
  401. tags []string
  402. wantDeleted []int
  403. }{
  404. {
  405. name: "newly selected tag is imported, not swept",
  406. storedTags: []string{"keep"},
  407. mode: "selected",
  408. tags: []string{"keep", "fresh"},
  409. wantDeleted: nil,
  410. },
  411. {
  412. name: "switch to all mode imports before sweeping",
  413. storedTags: []string{"keep"},
  414. mode: "all",
  415. wantDeleted: nil,
  416. },
  417. {
  418. name: "unchanged selection still sweeps a deleted tag",
  419. storedTags: []string{"keep", "gone"},
  420. mode: "selected",
  421. tags: []string{"keep", "gone"},
  422. wantDeleted: []int{3},
  423. },
  424. }
  425. for _, tc := range cases {
  426. t.Run(tc.name, func(t *testing.T) {
  427. setupConflictDB(t)
  428. ts, deletedIDs := fakeNodePanel(t, map[string]int{"keep": 1, "fresh": 2, "gone": 3})
  429. node := reconcileTestNode(t, ts, "grow-node", "selected", tc.storedTags)
  430. seedInboundConflictNode(t, "keep", "", 443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id)
  431. err := (&NodeService{}).UpdateFromRequest(node.Id, &NodeMutationRequest{
  432. Name: node.Name,
  433. Scheme: node.Scheme,
  434. Address: node.Address,
  435. Port: node.Port,
  436. BasePath: node.BasePath,
  437. Enable: true,
  438. AllowPrivateAddress: true,
  439. InboundSyncMode: tc.mode,
  440. InboundTags: tc.tags,
  441. })
  442. if err != nil {
  443. t.Fatalf("UpdateFromRequest: %v", err)
  444. }
  445. saved := &model.Node{}
  446. if err := database.GetDB().First(saved, node.Id).Error; err != nil {
  447. t.Fatalf("reload node: %v", err)
  448. }
  449. svc := InboundService{}
  450. if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(saved, nil), saved); err != nil {
  451. t.Fatalf("ReconcileNode: %v", err)
  452. }
  453. if got := deletedIDs(); !slices.Equal(got, tc.wantDeleted) {
  454. t.Fatalf("deleted remote ids = %v, want %v", got, tc.wantDeleted)
  455. }
  456. })
  457. }
  458. }