host_migration_test.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461
  1. package database
  2. import (
  3. "os"
  4. "path/filepath"
  5. "strings"
  6. "testing"
  7. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  8. )
  9. func initMigrateDB(t *testing.T) {
  10. t.Helper()
  11. if err := InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
  12. t.Fatalf("InitDB: %v", err)
  13. }
  14. t.Cleanup(func() { _ = CloseDB() })
  15. }
  16. func seedInboundWithStream(t *testing.T, tag string, port int, stream string) *model.Inbound {
  17. t.Helper()
  18. ib := &model.Inbound{
  19. UserId: 1, Tag: tag, Enable: true, Port: port, Protocol: model.VLESS,
  20. Remark: tag, Settings: `{"clients":[]}`, StreamSettings: stream,
  21. }
  22. if err := GetDB().Create(ib).Error; err != nil {
  23. t.Fatalf("create inbound %s: %v", tag, err)
  24. }
  25. return ib
  26. }
  27. const epMigrationStream = `{"network":"ws","security":"tls","externalProxy":[
  28. {"forceTls":"tls","dest":"a.cdn.com","port":8443,"remark":"A","sni":"a.sni","fingerprint":"chrome","alpn":["h2","h3"],"pinnedPeerCertSha256":["AAAA"],"echConfigList":"ECHV"},
  29. {"forceTls":"none","dest":"b.cdn.com","port":80,"remark":"B"}
  30. ]}`
  31. // #1 — each externalProxy entry becomes one host row with the exact field
  32. // mapping; sort_order is the entry index; inbound_id is correct.
  33. func TestMigrate_ExternalProxyToHosts(t *testing.T) {
  34. initMigrateDB(t)
  35. ib := seedInboundWithStream(t, "m1", 5551, epMigrationStream)
  36. if err := seedHostsFromExternalProxy(); err != nil {
  37. t.Fatalf("migrate: %v", err)
  38. }
  39. var hosts []model.Host
  40. if err := GetDB().Where("inbound_id = ?", ib.Id).Order("sort_order asc").Find(&hosts).Error; err != nil {
  41. t.Fatalf("load hosts: %v", err)
  42. }
  43. if len(hosts) != 2 {
  44. t.Fatalf("hosts = %d, want 2", len(hosts))
  45. }
  46. a := hosts[0]
  47. if a.InboundId != ib.Id || a.SortOrder != 0 || a.Security != "tls" || a.Address != "a.cdn.com" ||
  48. a.Port != 8443 || a.Remark != "A" || a.Sni != "a.sni" || a.Fingerprint != "chrome" || a.EchConfigList != "ECHV" {
  49. t.Fatalf("host A mapping wrong: %+v", a)
  50. }
  51. if len(a.Alpn) != 2 || a.Alpn[0] != "h2" || a.Alpn[1] != "h3" {
  52. t.Fatalf("host A alpn = %v, want [h2 h3]", a.Alpn)
  53. }
  54. if len(a.PinnedPeerCertSha256) != 1 || a.PinnedPeerCertSha256[0] != "AAAA" {
  55. t.Fatalf("host A pins = %v, want [AAAA]", a.PinnedPeerCertSha256)
  56. }
  57. b := hosts[1]
  58. if b.InboundId != ib.Id || b.SortOrder != 1 || b.Security != "none" || b.Address != "b.cdn.com" ||
  59. b.Port != 80 || b.Remark != "B" {
  60. t.Fatalf("host B mapping wrong: %+v", b)
  61. }
  62. if a.GroupId == "" || b.GroupId == "" {
  63. t.Fatalf("group ids must be assigned at creation: a=%q b=%q", a.GroupId, b.GroupId)
  64. }
  65. if a.GroupId == b.GroupId {
  66. t.Fatalf("each entry must get its own group id, both = %q", a.GroupId)
  67. }
  68. }
  69. // #1b — a hosts row that entered the DB without a group_id (older-build import
  70. // or restored backup) is repaired on every start, so it stays addressable by
  71. // the group-scoped update/delete API instead of surfacing as fallback_<id>.
  72. func TestBackfillEmptyHostGroupIds_RepairsLegacyRows(t *testing.T) {
  73. initMigrateDB(t)
  74. ib := seedInboundWithStream(t, "m1b", 5556, `{"network":"tcp","security":"none"}`)
  75. legacy := &model.Host{InboundId: ib.Id, Remark: "legacy", Address: "c.cdn.com", Port: 443, Security: "tls"}
  76. if err := GetDB().Create(legacy).Error; err != nil {
  77. t.Fatalf("create legacy host: %v", err)
  78. }
  79. if err := backfillEmptyHostGroupIds(); err != nil {
  80. t.Fatalf("backfill: %v", err)
  81. }
  82. var got model.Host
  83. if err := GetDB().First(&got, legacy.Id).Error; err != nil {
  84. t.Fatalf("reload host: %v", err)
  85. }
  86. if got.GroupId == "" {
  87. t.Fatal("group_id still empty after backfill")
  88. }
  89. if err := backfillEmptyHostGroupIds(); err != nil {
  90. t.Fatalf("second backfill: %v", err)
  91. }
  92. var again model.Host
  93. if err := GetDB().First(&again, legacy.Id).Error; err != nil {
  94. t.Fatalf("reload host after second run: %v", err)
  95. }
  96. if again.GroupId != got.GroupId {
  97. t.Fatalf("second run must not touch repaired rows: %q -> %q", got.GroupId, again.GroupId)
  98. }
  99. }
  100. // verify_peer_cert_by_name began as a boolean; AutoMigrate's cast to text turned it
  101. // into the cert name "0"/"1", which xray clients then fail to verify against.
  102. func TestMigrate_LegacyBooleanVerifyPeerCertByNameIsBlanked(t *testing.T) {
  103. dbPath := filepath.Join(t.TempDir(), "x-ui.db")
  104. if err := InitDB(dbPath); err != nil {
  105. t.Fatalf("InitDB: %v", err)
  106. }
  107. t.Cleanup(func() { _ = CloseDB() })
  108. ib := seedInboundWithStream(t, "vcn-legacy", 5557, `{"network":"tcp","security":"tls"}`)
  109. for _, stmt := range []string{
  110. "ALTER TABLE hosts DROP COLUMN verify_peer_cert_by_name",
  111. "ALTER TABLE hosts ADD COLUMN verify_peer_cert_by_name numeric",
  112. } {
  113. if err := GetDB().Exec(stmt).Error; err != nil {
  114. t.Fatalf("%s: %v", stmt, err)
  115. }
  116. }
  117. legacy := map[string]any{"bool-false": false, "bool-true": true, "named": "cert.example.com"}
  118. for remark, vcn := range legacy {
  119. if err := GetDB().Exec(
  120. "INSERT INTO hosts (group_id, inbound_id, remark, address, security, verify_peer_cert_by_name) VALUES (?, ?, ?, 'cdn.example.com', 'tls', ?)",
  121. remark, ib.Id, remark, vcn,
  122. ).Error; err != nil {
  123. t.Fatalf("insert legacy host %s: %v", remark, err)
  124. }
  125. }
  126. if err := GetDB().Where("seeder_name = ?", "HostVerifyPeerCertByNameBoolFix").Delete(&model.HistoryOfSeeders{}).Error; err != nil {
  127. t.Fatalf("clear seeder history: %v", err)
  128. }
  129. if err := CloseDB(); err != nil {
  130. t.Fatalf("CloseDB: %v", err)
  131. }
  132. if err := InitDB(dbPath); err != nil {
  133. t.Fatalf("reopen InitDB: %v", err)
  134. }
  135. var hosts []model.Host
  136. if err := GetDB().Where("inbound_id = ?", ib.Id).Find(&hosts).Error; err != nil {
  137. t.Fatalf("load hosts: %v", err)
  138. }
  139. if len(hosts) != len(legacy) {
  140. t.Fatalf("hosts = %d, want %d", len(hosts), len(legacy))
  141. }
  142. for _, h := range hosts {
  143. want := ""
  144. if h.Remark == "named" {
  145. want = "cert.example.com"
  146. }
  147. if h.VerifyPeerCertByName != want {
  148. t.Errorf("host %s: verifyPeerCertByName = %q, want %q", h.Remark, h.VerifyPeerCertByName, want)
  149. }
  150. }
  151. }
  152. // Postgres casts the boolean column with USING col::text whenever the hosts table
  153. // also lacks a newer column, the shape every boolean-era table had.
  154. func TestMigrate_LegacyBooleanVerifyPeerCertByNameIsBlanked_Postgres(t *testing.T) {
  155. if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
  156. t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres migration test")
  157. }
  158. if err := InitDB(""); err != nil {
  159. t.Fatalf("InitDB: %v", err)
  160. }
  161. t.Cleanup(func() { _ = CloseDB() })
  162. GetDB().Exec("TRUNCATE TABLE hosts, inbounds RESTART IDENTITY CASCADE")
  163. ib := seedInboundWithStream(t, "vcn-legacy-pg", 5558, `{"network":"tcp","security":"tls"}`)
  164. for _, stmt := range []string{
  165. "ALTER TABLE hosts DROP COLUMN cipher_suites",
  166. "ALTER TABLE hosts DROP COLUMN verify_peer_cert_by_name",
  167. "ALTER TABLE hosts ADD COLUMN verify_peer_cert_by_name boolean DEFAULT false",
  168. } {
  169. if err := GetDB().Exec(stmt).Error; err != nil {
  170. t.Fatalf("%s: %v", stmt, err)
  171. }
  172. }
  173. for _, vcn := range []bool{false, true} {
  174. if err := GetDB().Exec(
  175. "INSERT INTO hosts (group_id, inbound_id, remark, address, security, verify_peer_cert_by_name) VALUES ('g', ?, 'legacy', 'cdn.example.com', 'tls', ?)",
  176. ib.Id, vcn,
  177. ).Error; err != nil {
  178. t.Fatalf("insert legacy host %v: %v", vcn, err)
  179. }
  180. }
  181. if err := GetDB().Where("seeder_name = ?", "HostVerifyPeerCertByNameBoolFix").Delete(&model.HistoryOfSeeders{}).Error; err != nil {
  182. t.Fatalf("clear seeder history: %v", err)
  183. }
  184. if err := CloseDB(); err != nil {
  185. t.Fatalf("CloseDB: %v", err)
  186. }
  187. if err := InitDB(""); err != nil {
  188. t.Fatalf("reopen InitDB: %v", err)
  189. }
  190. var names []string
  191. if err := GetDB().Model(&model.Host{}).Where("inbound_id = ?", ib.Id).Pluck("verify_peer_cert_by_name", &names).Error; err != nil {
  192. t.Fatalf("load hosts: %v", err)
  193. }
  194. if len(names) != 2 || names[0] != "" || names[1] != "" {
  195. t.Fatalf("verifyPeerCertByName = %q, want two blanks", names)
  196. }
  197. }
  198. // #2 — a second run is a no-op (the HistoryOfSeeders gate).
  199. func TestMigrate_Idempotent(t *testing.T) {
  200. initMigrateDB(t)
  201. seedInboundWithStream(t, "m2", 5552, epMigrationStream)
  202. if err := seedHostsFromExternalProxy(); err != nil {
  203. t.Fatalf("first run: %v", err)
  204. }
  205. if err := seedHostsFromExternalProxy(); err != nil {
  206. t.Fatalf("second run: %v", err)
  207. }
  208. var count int64
  209. GetDB().Model(&model.Host{}).Count(&count)
  210. if count != 2 {
  211. t.Fatalf("host count = %d, want 2 (second run must be a no-op)", count)
  212. }
  213. }
  214. // #3 — inbounds without externalProxy create no hosts.
  215. func TestMigrate_NoExternalProxy_NoHosts(t *testing.T) {
  216. initMigrateDB(t)
  217. seedInboundWithStream(t, "m3", 5553, `{"network":"tcp","security":"none"}`)
  218. if err := seedHostsFromExternalProxy(); err != nil {
  219. t.Fatalf("migrate: %v", err)
  220. }
  221. var count int64
  222. GetDB().Model(&model.Host{}).Count(&count)
  223. if count != 0 {
  224. t.Fatalf("host count = %d, want 0", count)
  225. }
  226. }
  227. // #4 — externalProxy stays in StreamSettings (additive, rollback-safe).
  228. func TestMigrate_KeepsExternalProxyIntact(t *testing.T) {
  229. initMigrateDB(t)
  230. ib := seedInboundWithStream(t, "m4", 5554, epMigrationStream)
  231. if err := seedHostsFromExternalProxy(); err != nil {
  232. t.Fatalf("migrate: %v", err)
  233. }
  234. var got model.Inbound
  235. if err := GetDB().First(&got, ib.Id).Error; err != nil {
  236. t.Fatalf("reload inbound: %v", err)
  237. }
  238. if !strings.Contains(got.StreamSettings, "externalProxy") || !strings.Contains(got.StreamSettings, "a.cdn.com") {
  239. t.Fatalf("externalProxy must remain in StreamSettings: %s", got.StreamSettings)
  240. }
  241. }
  242. // #5 — same against a real Postgres DSN (sequence resync); skips without a DSN.
  243. func TestMigrate_Postgres(t *testing.T) {
  244. if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
  245. t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres migration test")
  246. }
  247. if err := InitDB(""); err != nil {
  248. t.Fatalf("InitDB: %v", err)
  249. }
  250. t.Cleanup(func() { _ = CloseDB() })
  251. // Clean slate so this run owns the migration regardless of prior tests.
  252. GetDB().Exec("TRUNCATE TABLE hosts, inbounds RESTART IDENTITY CASCADE")
  253. GetDB().Where("seeder_name = ?", "HostsFromExternalProxy").Delete(&model.HistoryOfSeeders{})
  254. seedInboundWithStream(t, "mpg", 5555, epMigrationStream)
  255. if err := seedHostsFromExternalProxy(); err != nil {
  256. t.Fatalf("migrate pg: %v", err)
  257. }
  258. var count int64
  259. GetDB().Model(&model.Host{}).Count(&count)
  260. if count != 2 {
  261. t.Fatalf("pg host count = %d, want 2", count)
  262. }
  263. if err := seedHostsFromExternalProxy(); err != nil {
  264. t.Fatalf("migrate pg (2nd): %v", err)
  265. }
  266. GetDB().Model(&model.Host{}).Count(&count)
  267. if count != 2 {
  268. t.Fatalf("pg host count after 2nd run = %d, want 2 (idempotent)", count)
  269. }
  270. }
  271. func TestMigrateMtprotoCustomShareAddrToHosts(t *testing.T) {
  272. initMigrateDB(t)
  273. ib := &model.Inbound{
  274. UserId: 1, Tag: "mtproto-custom", Enable: true, Port: 4060, Protocol: model.MTProto,
  275. Remark: "MTProto", Settings: `{"clients":[]}`, StreamSettings: `{}`,
  276. ShareAddrStrategy: "custom", ShareAddr: "proxy.example.com",
  277. }
  278. if err := GetDB().Create(ib).Error; err != nil {
  279. t.Fatalf("create inbound: %v", err)
  280. }
  281. if err := seedMtprotoCustomShareAddrToHosts(); err != nil {
  282. t.Fatalf("migrate: %v", err)
  283. }
  284. var got model.Inbound
  285. if err := GetDB().First(&got, ib.Id).Error; err != nil {
  286. t.Fatalf("reload inbound: %v", err)
  287. }
  288. if got.ShareAddrStrategy != "listen" || got.ShareAddr != "" {
  289. t.Fatalf("share fields = (%q, %q), want (listen, empty)", got.ShareAddrStrategy, got.ShareAddr)
  290. }
  291. var hosts []model.Host
  292. if err := GetDB().Where("inbound_id = ?", ib.Id).Find(&hosts).Error; err != nil {
  293. t.Fatalf("load hosts: %v", err)
  294. }
  295. if len(hosts) != 1 || hosts[0].Remark != "proxy.example.com" || hosts[0].Address != "proxy.example.com" || hosts[0].Port != 0 || hosts[0].Security != "same" {
  296. t.Fatalf("migrated hosts = %+v", hosts)
  297. }
  298. if hosts[0].GroupId == "" {
  299. t.Fatal("migrated host has an empty group id")
  300. }
  301. if err := seedMtprotoCustomShareAddrToHosts(); err != nil {
  302. t.Fatalf("second migrate: %v", err)
  303. }
  304. var count int64
  305. if err := GetDB().Model(&model.Host{}).Where("inbound_id = ?", ib.Id).Count(&count).Error; err != nil {
  306. t.Fatalf("count hosts: %v", err)
  307. }
  308. if count != 1 {
  309. t.Fatalf("host count = %d, want 1", count)
  310. }
  311. }
  312. func TestMigrateMtprotoCustomShareAddrWithUnrelatedHost(t *testing.T) {
  313. initMigrateDB(t)
  314. ib := &model.Inbound{
  315. UserId: 1, Tag: "mtproto-host", Enable: true, Port: 4060, Protocol: model.MTProto,
  316. Remark: "MTProto", Settings: `{"clients":[]}`, StreamSettings: `{}`,
  317. ShareAddrStrategy: "custom", ShareAddr: "old.example.com",
  318. }
  319. if err := GetDB().Create(ib).Error; err != nil {
  320. t.Fatalf("create inbound: %v", err)
  321. }
  322. existing := &model.Host{
  323. GroupId: "existing", InboundId: ib.Id, Remark: "public",
  324. Address: "new.example.com", Port: 443, Security: "same", IsDisabled: true,
  325. }
  326. if err := GetDB().Create(existing).Error; err != nil {
  327. t.Fatalf("create host: %v", err)
  328. }
  329. if err := seedMtprotoCustomShareAddrToHosts(); err != nil {
  330. t.Fatalf("migrate: %v", err)
  331. }
  332. var hosts []model.Host
  333. if err := GetDB().Where("inbound_id = ?", ib.Id).Order("id asc").Find(&hosts).Error; err != nil {
  334. t.Fatalf("load hosts: %v", err)
  335. }
  336. if len(hosts) != 2 || hosts[0].Id != existing.Id {
  337. t.Fatalf("hosts = %+v, want the existing host plus the migrated one", hosts)
  338. }
  339. if hosts[1].Address != "old.example.com" || hosts[1].Port != 0 || hosts[1].IsDisabled {
  340. t.Fatalf("migrated host = %+v, want enabled old.example.com on the inbound port", hosts[1])
  341. }
  342. var got model.Inbound
  343. if err := GetDB().First(&got, ib.Id).Error; err != nil {
  344. t.Fatalf("reload inbound: %v", err)
  345. }
  346. if got.ShareAddrStrategy != "listen" || got.ShareAddr != "" {
  347. t.Fatalf("share fields = (%q, %q), want (listen, empty)", got.ShareAddrStrategy, got.ShareAddr)
  348. }
  349. }
  350. func TestMigrateMtprotoCustomShareAddrSkipsHostWithSameAddress(t *testing.T) {
  351. initMigrateDB(t)
  352. ib := &model.Inbound{
  353. UserId: 1, Tag: "mtproto-dup", Enable: true, Port: 4060, Protocol: model.MTProto,
  354. Remark: "MTProto", Settings: `{"clients":[]}`, StreamSettings: `{}`,
  355. ShareAddrStrategy: "custom", ShareAddr: "proxy.example.com",
  356. }
  357. if err := GetDB().Create(ib).Error; err != nil {
  358. t.Fatalf("create inbound: %v", err)
  359. }
  360. existing := &model.Host{
  361. GroupId: "existing", InboundId: ib.Id, Remark: "public",
  362. Address: "proxy.example.com", Port: 443, Security: "same",
  363. }
  364. if err := GetDB().Create(existing).Error; err != nil {
  365. t.Fatalf("create host: %v", err)
  366. }
  367. if err := seedMtprotoCustomShareAddrToHosts(); err != nil {
  368. t.Fatalf("migrate: %v", err)
  369. }
  370. var hosts []model.Host
  371. if err := GetDB().Where("inbound_id = ?", ib.Id).Find(&hosts).Error; err != nil {
  372. t.Fatalf("load hosts: %v", err)
  373. }
  374. if len(hosts) != 1 || hosts[0].Id != existing.Id {
  375. t.Fatalf("hosts = %+v, want only the existing host %d", hosts, existing.Id)
  376. }
  377. }
  378. func TestMigrateMtprotoCustomShareAddrWithUnusableSameAddressHost(t *testing.T) {
  379. cases := []struct {
  380. name string
  381. existing model.Host
  382. }{
  383. {"disabled", model.Host{IsDisabled: true}},
  384. {"excludes_raw", model.Host{ExcludeFromSubTypes: []string{"raw"}}},
  385. }
  386. for _, c := range cases {
  387. t.Run(c.name, func(t *testing.T) {
  388. initMigrateDB(t)
  389. ib := &model.Inbound{
  390. UserId: 1, Tag: "mtproto-" + c.name, Enable: true, Port: 4060, Protocol: model.MTProto,
  391. Remark: "MTProto", Settings: `{"clients":[]}`, StreamSettings: `{}`,
  392. ShareAddrStrategy: "custom", ShareAddr: "proxy.example.com",
  393. }
  394. if err := GetDB().Create(ib).Error; err != nil {
  395. t.Fatalf("create inbound: %v", err)
  396. }
  397. existing := c.existing
  398. existing.GroupId = "existing"
  399. existing.InboundId = ib.Id
  400. existing.Remark = "parked"
  401. existing.Address = "proxy.example.com"
  402. existing.Security = "same"
  403. if err := GetDB().Create(&existing).Error; err != nil {
  404. t.Fatalf("create host: %v", err)
  405. }
  406. if err := seedMtprotoCustomShareAddrToHosts(); err != nil {
  407. t.Fatalf("migrate: %v", err)
  408. }
  409. var hosts []model.Host
  410. if err := GetDB().Where("inbound_id = ?", ib.Id).Order("id asc").Find(&hosts).Error; err != nil {
  411. t.Fatalf("load hosts: %v", err)
  412. }
  413. if len(hosts) != 2 {
  414. t.Fatalf("hosts = %+v, want the parked host plus a usable one", hosts)
  415. }
  416. migrated := hosts[1]
  417. if migrated.Address != "proxy.example.com" || migrated.IsDisabled || len(migrated.ExcludeFromSubTypes) != 0 {
  418. t.Fatalf("migrated host = %+v, want an enabled raw-included proxy.example.com", migrated)
  419. }
  420. })
  421. }
  422. }