inbound_node_reconcile_test.go 15 KB

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