inbound_migration_test.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  1. package service
  2. import (
  3. "errors"
  4. "path/filepath"
  5. "strings"
  6. "testing"
  7. "gorm.io/gorm"
  8. "github.com/mhsanaei/3x-ui/v3/internal/database"
  9. "github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
  10. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  11. "github.com/mhsanaei/3x-ui/v3/internal/xray"
  12. )
  13. // TestMigrationRequirements_BackfillsClientTrafficsWithMultiDomainInbound guards the
  14. // PostgreSQL fix where the externalProxy detection query (executed via .Scan) errored on
  15. // json_extract and rolled back the whole transaction — including the client_traffics
  16. // backfill at inbound.go:3093-3106, leaving clients with no traffic rows. A MultiDomain
  17. // inbound is present so that query returns rows and the function runs to completion; both
  18. // the backfill and the MultiDomain→ExternalProxy migration must then commit.
  19. func TestMigrationRequirements_BackfillsClientTrafficsWithMultiDomainInbound(t *testing.T) {
  20. dbDir := t.TempDir()
  21. t.Setenv("XUI_DB_FOLDER", dbDir)
  22. dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
  23. db := database.GetDB()
  24. const backfillEmail = "[email protected]"
  25. const uid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0c010"
  26. // Inbound A: a client present only in settings.clients, with no client_traffics row.
  27. clientInbound := &model.Inbound{
  28. UserId: 1,
  29. Tag: "a-tag",
  30. Enable: true,
  31. Port: 30001,
  32. Protocol: model.VLESS,
  33. Settings: `{"clients":[{"email":"` + backfillEmail + `","id":"` + uid + `","enable":true}]}`,
  34. StreamSettings: `{"network":"tcp","security":"none"}`,
  35. }
  36. if err := db.Create(clientInbound).Error; err != nil {
  37. t.Fatalf("create client inbound: %v", err)
  38. }
  39. // Inbound B: a legacy MultiDomain inbound whose tag carries the 0.0.0.0: prefix.
  40. // Its presence makes the externalProxy query return rows, so the function does not
  41. // early-return and reaches the tag-cleanup statement.
  42. multiDomainInbound := &model.Inbound{
  43. UserId: 1,
  44. Tag: "inbound-0.0.0.0:30002",
  45. Enable: true,
  46. Port: 30002,
  47. Protocol: model.VLESS,
  48. Settings: `{"clients":[]}`,
  49. StreamSettings: `{"security":"tls","tlsSettings":{"settings":{"domains":[{"domain":"example.com"}]}}}`,
  50. }
  51. if err := db.Create(multiDomainInbound).Error; err != nil {
  52. t.Fatalf("create multidomain inbound: %v", err)
  53. }
  54. var before int64
  55. if err := db.Model(xray.ClientTraffic{}).Count(&before).Error; err != nil {
  56. t.Fatalf("count client_traffics before: %v", err)
  57. }
  58. if before != 0 {
  59. t.Fatalf("expected no client_traffics before migration, got %d", before)
  60. }
  61. svc := InboundService{}
  62. svc.MigrationRequirements()
  63. // The backfill must have committed: the settings-only client now owns a row.
  64. // Before the fix this was rolled back whenever the externalProxy detection query
  65. // errored (it does on Postgres via json_extract), so the MultiDomain inbound below
  66. // is deliberately present to make that query return rows and run to completion.
  67. var ct xray.ClientTraffic
  68. if err := db.Model(xray.ClientTraffic{}).Where("email = ?", backfillEmail).First(&ct).Error; err != nil {
  69. t.Fatalf("client_traffics row not backfilled for %s: %v", backfillEmail, err)
  70. }
  71. // The MultiDomain→ExternalProxy migration must have committed too: the detection
  72. // query ran (.Scan executes it) and the loop rewrote the inbound's streamSettings.
  73. var refreshed model.Inbound
  74. if err := db.First(&refreshed, multiDomainInbound.Id).Error; err != nil {
  75. t.Fatalf("reload multidomain inbound: %v", err)
  76. }
  77. if !strings.Contains(refreshed.StreamSettings, "externalProxy") {
  78. t.Errorf("MultiDomain migration did not commit; streamSettings = %q", refreshed.StreamSettings)
  79. }
  80. }
  81. func TestMigrationRequirementsReturnsAddClientStatFailure(t *testing.T) {
  82. dbDir := t.TempDir()
  83. t.Setenv("XUI_DB_FOLDER", dbDir)
  84. dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
  85. db := database.GetDB()
  86. first := &model.Inbound{UserId: 1, Tag: "first", Port: 31001, Protocol: model.VLESS, Settings: `{"clients":[{"email":"[email protected]","id":"id-1"}]}`, StreamSettings: `{}`}
  87. if err := db.Create(first).Error; err != nil {
  88. t.Fatalf("create first: %v", err)
  89. }
  90. const injected = "injected AddClientStat failure"
  91. failSave := func(tx *gorm.DB) {
  92. tx.AddError(errors.New(injected))
  93. }
  94. if err := db.Callback().Update().Before("gorm:update").Register("test:fail-migration-inbound-save", failSave); err != nil {
  95. t.Fatalf("register update callback: %v", err)
  96. }
  97. if err := db.Callback().Create().Before("gorm:create").Register("test:fail-migration-inbound-save", failSave); err != nil {
  98. t.Fatalf("register create callback: %v", err)
  99. }
  100. err := (&InboundService{}).MigrationRequirements()
  101. if err == nil || err.Error() != injected {
  102. t.Fatalf("MigrationRequirements error = %v, want %q", err, injected)
  103. }
  104. var count int64
  105. if err := db.Model(&xray.ClientTraffic{}).Where("email = ?", "[email protected]").Count(&count).Error; err != nil {
  106. t.Fatalf("count rolled-back traffic: %v", err)
  107. }
  108. if count != 0 {
  109. t.Fatalf("earlier traffic write committed after save failure: count=%d", count)
  110. }
  111. }
  112. // TestMigrationRequirements_CleansLegacyZeroAddrTag guards the legacy tag cleanup that
  113. // strips the auto-generated "0.0.0.0:" prefix. The inbound is MultiDomain TLS so the
  114. // externalProxy detection query returns rows and the cleanup is reached (it early-returns
  115. // at len(externalProxy)==0 otherwise). The cleanup must use tx.Exec, not tx.Raw, which
  116. // only builds a non-SELECT statement without running it.
  117. func TestMigrationRequirements_CleansLegacyZeroAddrTag(t *testing.T) {
  118. dbDir := t.TempDir()
  119. t.Setenv("XUI_DB_FOLDER", dbDir)
  120. dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
  121. db := database.GetDB()
  122. legacy := &model.Inbound{
  123. UserId: 1,
  124. Tag: "inbound-0.0.0.0:30003",
  125. Enable: true,
  126. Port: 30003,
  127. Protocol: model.VLESS,
  128. Settings: `{"clients":[]}`,
  129. StreamSettings: `{"security":"tls","tlsSettings":{"settings":{"domains":[{"domain":"example.com"}]}}}`,
  130. }
  131. if err := db.Create(legacy).Error; err != nil {
  132. t.Fatalf("create legacy inbound: %v", err)
  133. }
  134. svc := InboundService{}
  135. svc.MigrationRequirements()
  136. var got model.Inbound
  137. if err := db.First(&got, legacy.Id).Error; err != nil {
  138. t.Fatalf("reload inbound: %v", err)
  139. }
  140. if got.Tag != "inbound-30003" {
  141. t.Fatalf("legacy 0.0.0.0: tag not stripped: got %q, want %q", got.Tag, "inbound-30003")
  142. }
  143. }
  144. func TestMigrationRequirements_SkipsLegacyZeroAddrTagCollision(t *testing.T) {
  145. setupConflictDB(t)
  146. db := database.GetDB()
  147. existing := &model.Inbound{
  148. UserId: 1,
  149. Tag: "inbound-30004",
  150. Enable: true,
  151. Port: 30004,
  152. Protocol: model.VLESS,
  153. Settings: `{"clients":[]}`,
  154. StreamSettings: `{"network":"tcp","security":"none"}`,
  155. }
  156. legacy := &model.Inbound{
  157. UserId: 1,
  158. Tag: "inbound-0.0.0.0:30004",
  159. Enable: true,
  160. Port: 30005,
  161. Protocol: model.VLESS,
  162. Settings: `{"clients":[]}`,
  163. StreamSettings: `{"security":"tls","tlsSettings":{"settings":{"domains":[{"domain":"example.com"}]}}}`,
  164. }
  165. if err := db.Create(existing).Error; err != nil {
  166. t.Fatalf("create existing inbound: %v", err)
  167. }
  168. if err := db.Create(legacy).Error; err != nil {
  169. t.Fatalf("create legacy inbound: %v", err)
  170. }
  171. svc := InboundService{}
  172. // The cleanup shares a transaction with every other requirement, so a unique
  173. // violation here rolls all of them back and only reaches the log.
  174. if err := svc.MigrationRequirements(); err != nil {
  175. t.Fatalf("MigrationRequirements: %v", err)
  176. }
  177. var got model.Inbound
  178. if err := db.First(&got, legacy.Id).Error; err != nil {
  179. t.Fatalf("reload legacy inbound: %v", err)
  180. }
  181. if got.Tag != "inbound-0.0.0.0:30004" {
  182. t.Fatalf("colliding legacy tag should be left unchanged, got %q", got.Tag)
  183. }
  184. var count int64
  185. if err := db.Model(&model.Inbound{}).Where("tag = ?", "inbound-30004").Count(&count).Error; err != nil {
  186. t.Fatalf("count existing tag: %v", err)
  187. }
  188. if count != 1 {
  189. t.Fatalf("target tag count = %d, want 1", count)
  190. }
  191. }
  192. func TestMigrationRemoveOrphanedTraffics(t *testing.T) {
  193. setupConflictDB(t)
  194. db := database.GetDB()
  195. clientSvc := &ClientService{}
  196. inboundSvc := &InboundService{}
  197. const attachedEmail = "[email protected]"
  198. attachedClient := model.Client{Email: attachedEmail, ID: "11111111-1111-1111-1111-111111111111", SubID: attachedEmail, Enable: true}
  199. attachedIb := mkInbound(t, 30003, model.VLESS, clientsSettings(t, []model.Client{attachedClient}))
  200. if err := clientSvc.SyncInbound(nil, attachedIb.Id, []model.Client{attachedClient}); err != nil {
  201. t.Fatalf("seed attached client: %v", err)
  202. }
  203. mkTraffic(t, attachedIb.Id, attachedEmail, 0, 0, 0, 0, true)
  204. const detachedEmail = "[email protected]"
  205. detachedClient := model.Client{Email: detachedEmail, ID: "22222222-2222-2222-2222-222222222222", SubID: detachedEmail, Enable: true}
  206. detachedIb := mkInbound(t, 30004, model.VLESS, clientsSettings(t, []model.Client{detachedClient}))
  207. if err := clientSvc.SyncInbound(nil, detachedIb.Id, []model.Client{detachedClient}); err != nil {
  208. t.Fatalf("seed detached client: %v", err)
  209. }
  210. mkTraffic(t, detachedIb.Id, detachedEmail, 123, 456, 0, 0, true)
  211. detachedRec := lookupClientRecord(t, detachedEmail)
  212. if _, err := clientSvc.Detach(inboundSvc, detachedRec.Id, []int{detachedIb.Id}); err != nil {
  213. t.Fatalf("Detach: %v", err)
  214. }
  215. const jsonOnlyEmail = "[email protected]"
  216. jsonOnlyClient := model.Client{Email: jsonOnlyEmail, ID: "33333333-3333-3333-3333-333333333333", SubID: jsonOnlyEmail, Enable: true}
  217. jsonOnlyIb := mkInbound(t, 30005, model.VLESS, clientsSettings(t, []model.Client{jsonOnlyClient}))
  218. mkTraffic(t, jsonOnlyIb.Id, jsonOnlyEmail, 0, 0, 0, 0, true)
  219. const trulyOrphanedEmail = "[email protected]"
  220. mkTraffic(t, attachedIb.Id, trulyOrphanedEmail, 0, 0, 0, 0, true)
  221. inboundSvc.MigrationRemoveOrphanedTraffics()
  222. cases := []struct {
  223. name string
  224. email string
  225. want int64
  226. }{
  227. {"attached, in clients table and JSON", attachedEmail, 1},
  228. {"detached-but-alive, in clients table only", detachedEmail, 1},
  229. {"seeder-skipped-but-live, in JSON only", jsonOnlyEmail, 1},
  230. {"truly orphaned, in neither", trulyOrphanedEmail, 0},
  231. }
  232. for _, c := range cases {
  233. t.Run(c.name, func(t *testing.T) {
  234. var got int64
  235. if err := db.Model(xray.ClientTraffic{}).Where("email = ?", c.email).Count(&got).Error; err != nil {
  236. t.Fatalf("count client_traffics for %s: %v", c.email, err)
  237. }
  238. if got != c.want {
  239. t.Errorf("client_traffics count for %s: got %d, want %d", c.email, got, c.want)
  240. }
  241. })
  242. }
  243. }
  244. func TestMigrationRequirements_NormalizesShareAddressFields(t *testing.T) {
  245. setupConflictDB(t)
  246. db := database.GetDB()
  247. invalidStrategy := &model.Inbound{
  248. UserId: 1,
  249. Tag: "invalid-share-strategy",
  250. Enable: true,
  251. Port: 31001,
  252. Protocol: model.VLESS,
  253. Settings: `{"clients":[]}`,
  254. StreamSettings: `{"network":"tcp","security":"none"}`,
  255. }
  256. paddedStrategy := &model.Inbound{
  257. UserId: 1,
  258. Tag: "padded-share-strategy",
  259. Enable: true,
  260. Port: 31002,
  261. Protocol: model.VLESS,
  262. Settings: `{"clients":[]}`,
  263. StreamSettings: `{"network":"tcp","security":"none"}`,
  264. }
  265. invalidAddress := &model.Inbound{
  266. UserId: 1,
  267. Tag: "invalid-share-address",
  268. Enable: true,
  269. Port: 31003,
  270. Protocol: model.VLESS,
  271. Settings: `{"clients":[]}`,
  272. StreamSettings: `{"network":"tcp","security":"none"}`,
  273. }
  274. if err := db.Create(invalidStrategy).Error; err != nil {
  275. t.Fatalf("create invalid strategy inbound: %v", err)
  276. }
  277. if err := db.Create(paddedStrategy).Error; err != nil {
  278. t.Fatalf("create padded strategy inbound: %v", err)
  279. }
  280. if err := db.Create(invalidAddress).Error; err != nil {
  281. t.Fatalf("create invalid address inbound: %v", err)
  282. }
  283. if err := db.Model(&model.Inbound{}).Where("id = ?", invalidStrategy.Id).Updates(map[string]any{
  284. "share_addr_strategy": " auto ",
  285. "share_addr": " edge.example.com ",
  286. }).Error; err != nil {
  287. t.Fatalf("seed invalid share fields: %v", err)
  288. }
  289. if err := db.Model(&model.Inbound{}).Where("id = ?", paddedStrategy.Id).Updates(map[string]any{
  290. "share_addr_strategy": " listen ",
  291. "share_addr": " 10.0.0.1 ",
  292. }).Error; err != nil {
  293. t.Fatalf("seed padded share fields: %v", err)
  294. }
  295. if err := db.Model(&model.Inbound{}).Where("id = ?", invalidAddress.Id).Updates(map[string]any{
  296. "share_addr_strategy": "custom",
  297. "share_addr": "edge.example.com:8443",
  298. }).Error; err != nil {
  299. t.Fatalf("seed invalid address share fields: %v", err)
  300. }
  301. svc := InboundService{}
  302. svc.MigrationRequirements()
  303. var gotInvalid model.Inbound
  304. if err := db.First(&gotInvalid, invalidStrategy.Id).Error; err != nil {
  305. t.Fatalf("reload invalid strategy inbound: %v", err)
  306. }
  307. if gotInvalid.ShareAddrStrategy != "node" || gotInvalid.ShareAddr != "edge.example.com" {
  308. t.Fatalf("invalid share fields = (%q, %q), want (node, edge.example.com)", gotInvalid.ShareAddrStrategy, gotInvalid.ShareAddr)
  309. }
  310. var gotPadded model.Inbound
  311. if err := db.First(&gotPadded, paddedStrategy.Id).Error; err != nil {
  312. t.Fatalf("reload padded strategy inbound: %v", err)
  313. }
  314. if gotPadded.ShareAddrStrategy != "listen" || gotPadded.ShareAddr != "10.0.0.1" {
  315. t.Fatalf("padded share fields = (%q, %q), want (listen, 10.0.0.1)", gotPadded.ShareAddrStrategy, gotPadded.ShareAddr)
  316. }
  317. var gotInvalidAddress model.Inbound
  318. if err := db.First(&gotInvalidAddress, invalidAddress.Id).Error; err != nil {
  319. t.Fatalf("reload invalid address inbound: %v", err)
  320. }
  321. if gotInvalidAddress.ShareAddrStrategy != "node" || gotInvalidAddress.ShareAddr != "" {
  322. t.Fatalf("invalid address share fields = (%q, %q), want (node, empty)", gotInvalidAddress.ShareAddrStrategy, gotInvalidAddress.ShareAddr)
  323. }
  324. }