Files
3x-ui/internal/database/host_migration_test.go
T
MHSanaei 74c3cc3882 fix(database): blank boolean-era verifyPeerCertByName values on hosts
A host's verify_peer_cert_by_name must only hold a server name an
operator typed. The column started life as a boolean, and the migration
that was meant to discard those values runs after AutoMigrate, which has
already cast the column to text: SQLite's table rebuild stores 0/1 as
"0"/"1", and Postgres' USING col::text stores "false"/"true". The
migration then sees a text column and leaves the values alone.

Since 3.10.0 the JSON subscription lifts host TLS verification fields to
the level xray reads, so that leftover "0" is now sent as the cert name to
verify and clients such as Happ refuse the server certificate.

A one-shot HostVerifyPeerCertByNameBoolFix seeder blanks exactly the four
cast values on existing databases; real names are untouched. The
Postgres variant joins the CI schema job, since that cast only happens
there.
2026-10-12 18:16:22 +02:00

462 lines
16 KiB
Go

package database
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func initMigrateDB(t *testing.T) {
t.Helper()
if err := InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
}
func seedInboundWithStream(t *testing.T, tag string, port int, stream string) *model.Inbound {
t.Helper()
ib := &model.Inbound{
UserId: 1, Tag: tag, Enable: true, Port: port, Protocol: model.VLESS,
Remark: tag, Settings: `{"clients":[]}`, StreamSettings: stream,
}
if err := GetDB().Create(ib).Error; err != nil {
t.Fatalf("create inbound %s: %v", tag, err)
}
return ib
}
const epMigrationStream = `{"network":"ws","security":"tls","externalProxy":[
{"forceTls":"tls","dest":"a.cdn.com","port":8443,"remark":"A","sni":"a.sni","fingerprint":"chrome","alpn":["h2","h3"],"pinnedPeerCertSha256":["AAAA"],"echConfigList":"ECHV"},
{"forceTls":"none","dest":"b.cdn.com","port":80,"remark":"B"}
]}`
// #1 — each externalProxy entry becomes one host row with the exact field
// mapping; sort_order is the entry index; inbound_id is correct.
func TestMigrate_ExternalProxyToHosts(t *testing.T) {
initMigrateDB(t)
ib := seedInboundWithStream(t, "m1", 5551, epMigrationStream)
if err := seedHostsFromExternalProxy(); err != nil {
t.Fatalf("migrate: %v", err)
}
var hosts []model.Host
if err := GetDB().Where("inbound_id = ?", ib.Id).Order("sort_order asc").Find(&hosts).Error; err != nil {
t.Fatalf("load hosts: %v", err)
}
if len(hosts) != 2 {
t.Fatalf("hosts = %d, want 2", len(hosts))
}
a := hosts[0]
if a.InboundId != ib.Id || a.SortOrder != 0 || a.Security != "tls" || a.Address != "a.cdn.com" ||
a.Port != 8443 || a.Remark != "A" || a.Sni != "a.sni" || a.Fingerprint != "chrome" || a.EchConfigList != "ECHV" {
t.Fatalf("host A mapping wrong: %+v", a)
}
if len(a.Alpn) != 2 || a.Alpn[0] != "h2" || a.Alpn[1] != "h3" {
t.Fatalf("host A alpn = %v, want [h2 h3]", a.Alpn)
}
if len(a.PinnedPeerCertSha256) != 1 || a.PinnedPeerCertSha256[0] != "AAAA" {
t.Fatalf("host A pins = %v, want [AAAA]", a.PinnedPeerCertSha256)
}
b := hosts[1]
if b.InboundId != ib.Id || b.SortOrder != 1 || b.Security != "none" || b.Address != "b.cdn.com" ||
b.Port != 80 || b.Remark != "B" {
t.Fatalf("host B mapping wrong: %+v", b)
}
if a.GroupId == "" || b.GroupId == "" {
t.Fatalf("group ids must be assigned at creation: a=%q b=%q", a.GroupId, b.GroupId)
}
if a.GroupId == b.GroupId {
t.Fatalf("each entry must get its own group id, both = %q", a.GroupId)
}
}
// #1b — a hosts row that entered the DB without a group_id (older-build import
// or restored backup) is repaired on every start, so it stays addressable by
// the group-scoped update/delete API instead of surfacing as fallback_<id>.
func TestBackfillEmptyHostGroupIds_RepairsLegacyRows(t *testing.T) {
initMigrateDB(t)
ib := seedInboundWithStream(t, "m1b", 5556, `{"network":"tcp","security":"none"}`)
legacy := &model.Host{InboundId: ib.Id, Remark: "legacy", Address: "c.cdn.com", Port: 443, Security: "tls"}
if err := GetDB().Create(legacy).Error; err != nil {
t.Fatalf("create legacy host: %v", err)
}
if err := backfillEmptyHostGroupIds(); err != nil {
t.Fatalf("backfill: %v", err)
}
var got model.Host
if err := GetDB().First(&got, legacy.Id).Error; err != nil {
t.Fatalf("reload host: %v", err)
}
if got.GroupId == "" {
t.Fatal("group_id still empty after backfill")
}
if err := backfillEmptyHostGroupIds(); err != nil {
t.Fatalf("second backfill: %v", err)
}
var again model.Host
if err := GetDB().First(&again, legacy.Id).Error; err != nil {
t.Fatalf("reload host after second run: %v", err)
}
if again.GroupId != got.GroupId {
t.Fatalf("second run must not touch repaired rows: %q -> %q", got.GroupId, again.GroupId)
}
}
// verify_peer_cert_by_name began as a boolean; AutoMigrate's cast to text turned it
// into the cert name "0"/"1", which xray clients then fail to verify against.
func TestMigrate_LegacyBooleanVerifyPeerCertByNameIsBlanked(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "x-ui.db")
if err := InitDB(dbPath); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
ib := seedInboundWithStream(t, "vcn-legacy", 5557, `{"network":"tcp","security":"tls"}`)
for _, stmt := range []string{
"ALTER TABLE hosts DROP COLUMN verify_peer_cert_by_name",
"ALTER TABLE hosts ADD COLUMN verify_peer_cert_by_name numeric",
} {
if err := GetDB().Exec(stmt).Error; err != nil {
t.Fatalf("%s: %v", stmt, err)
}
}
legacy := map[string]any{"bool-false": false, "bool-true": true, "named": "cert.example.com"}
for remark, vcn := range legacy {
if err := GetDB().Exec(
"INSERT INTO hosts (group_id, inbound_id, remark, address, security, verify_peer_cert_by_name) VALUES (?, ?, ?, 'cdn.example.com', 'tls', ?)",
remark, ib.Id, remark, vcn,
).Error; err != nil {
t.Fatalf("insert legacy host %s: %v", remark, err)
}
}
if err := GetDB().Where("seeder_name = ?", "HostVerifyPeerCertByNameBoolFix").Delete(&model.HistoryOfSeeders{}).Error; err != nil {
t.Fatalf("clear seeder history: %v", err)
}
if err := CloseDB(); err != nil {
t.Fatalf("CloseDB: %v", err)
}
if err := InitDB(dbPath); err != nil {
t.Fatalf("reopen InitDB: %v", err)
}
var hosts []model.Host
if err := GetDB().Where("inbound_id = ?", ib.Id).Find(&hosts).Error; err != nil {
t.Fatalf("load hosts: %v", err)
}
if len(hosts) != len(legacy) {
t.Fatalf("hosts = %d, want %d", len(hosts), len(legacy))
}
for _, h := range hosts {
want := ""
if h.Remark == "named" {
want = "cert.example.com"
}
if h.VerifyPeerCertByName != want {
t.Errorf("host %s: verifyPeerCertByName = %q, want %q", h.Remark, h.VerifyPeerCertByName, want)
}
}
}
// Postgres casts the boolean column with USING col::text whenever the hosts table
// also lacks a newer column, the shape every boolean-era table had.
func TestMigrate_LegacyBooleanVerifyPeerCertByNameIsBlanked_Postgres(t *testing.T) {
if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres migration test")
}
if err := InitDB(""); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
GetDB().Exec("TRUNCATE TABLE hosts, inbounds RESTART IDENTITY CASCADE")
ib := seedInboundWithStream(t, "vcn-legacy-pg", 5558, `{"network":"tcp","security":"tls"}`)
for _, stmt := range []string{
"ALTER TABLE hosts DROP COLUMN cipher_suites",
"ALTER TABLE hosts DROP COLUMN verify_peer_cert_by_name",
"ALTER TABLE hosts ADD COLUMN verify_peer_cert_by_name boolean DEFAULT false",
} {
if err := GetDB().Exec(stmt).Error; err != nil {
t.Fatalf("%s: %v", stmt, err)
}
}
for _, vcn := range []bool{false, true} {
if err := GetDB().Exec(
"INSERT INTO hosts (group_id, inbound_id, remark, address, security, verify_peer_cert_by_name) VALUES ('g', ?, 'legacy', 'cdn.example.com', 'tls', ?)",
ib.Id, vcn,
).Error; err != nil {
t.Fatalf("insert legacy host %v: %v", vcn, err)
}
}
if err := GetDB().Where("seeder_name = ?", "HostVerifyPeerCertByNameBoolFix").Delete(&model.HistoryOfSeeders{}).Error; err != nil {
t.Fatalf("clear seeder history: %v", err)
}
if err := CloseDB(); err != nil {
t.Fatalf("CloseDB: %v", err)
}
if err := InitDB(""); err != nil {
t.Fatalf("reopen InitDB: %v", err)
}
var names []string
if err := GetDB().Model(&model.Host{}).Where("inbound_id = ?", ib.Id).Pluck("verify_peer_cert_by_name", &names).Error; err != nil {
t.Fatalf("load hosts: %v", err)
}
if len(names) != 2 || names[0] != "" || names[1] != "" {
t.Fatalf("verifyPeerCertByName = %q, want two blanks", names)
}
}
// #2 — a second run is a no-op (the HistoryOfSeeders gate).
func TestMigrate_Idempotent(t *testing.T) {
initMigrateDB(t)
seedInboundWithStream(t, "m2", 5552, epMigrationStream)
if err := seedHostsFromExternalProxy(); err != nil {
t.Fatalf("first run: %v", err)
}
if err := seedHostsFromExternalProxy(); err != nil {
t.Fatalf("second run: %v", err)
}
var count int64
GetDB().Model(&model.Host{}).Count(&count)
if count != 2 {
t.Fatalf("host count = %d, want 2 (second run must be a no-op)", count)
}
}
// #3 — inbounds without externalProxy create no hosts.
func TestMigrate_NoExternalProxy_NoHosts(t *testing.T) {
initMigrateDB(t)
seedInboundWithStream(t, "m3", 5553, `{"network":"tcp","security":"none"}`)
if err := seedHostsFromExternalProxy(); err != nil {
t.Fatalf("migrate: %v", err)
}
var count int64
GetDB().Model(&model.Host{}).Count(&count)
if count != 0 {
t.Fatalf("host count = %d, want 0", count)
}
}
// #4 — externalProxy stays in StreamSettings (additive, rollback-safe).
func TestMigrate_KeepsExternalProxyIntact(t *testing.T) {
initMigrateDB(t)
ib := seedInboundWithStream(t, "m4", 5554, epMigrationStream)
if err := seedHostsFromExternalProxy(); err != nil {
t.Fatalf("migrate: %v", err)
}
var got model.Inbound
if err := GetDB().First(&got, ib.Id).Error; err != nil {
t.Fatalf("reload inbound: %v", err)
}
if !strings.Contains(got.StreamSettings, "externalProxy") || !strings.Contains(got.StreamSettings, "a.cdn.com") {
t.Fatalf("externalProxy must remain in StreamSettings: %s", got.StreamSettings)
}
}
// #5 — same against a real Postgres DSN (sequence resync); skips without a DSN.
func TestMigrate_Postgres(t *testing.T) {
if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres migration test")
}
if err := InitDB(""); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
// Clean slate so this run owns the migration regardless of prior tests.
GetDB().Exec("TRUNCATE TABLE hosts, inbounds RESTART IDENTITY CASCADE")
GetDB().Where("seeder_name = ?", "HostsFromExternalProxy").Delete(&model.HistoryOfSeeders{})
seedInboundWithStream(t, "mpg", 5555, epMigrationStream)
if err := seedHostsFromExternalProxy(); err != nil {
t.Fatalf("migrate pg: %v", err)
}
var count int64
GetDB().Model(&model.Host{}).Count(&count)
if count != 2 {
t.Fatalf("pg host count = %d, want 2", count)
}
if err := seedHostsFromExternalProxy(); err != nil {
t.Fatalf("migrate pg (2nd): %v", err)
}
GetDB().Model(&model.Host{}).Count(&count)
if count != 2 {
t.Fatalf("pg host count after 2nd run = %d, want 2 (idempotent)", count)
}
}
func TestMigrateMtprotoCustomShareAddrToHosts(t *testing.T) {
initMigrateDB(t)
ib := &model.Inbound{
UserId: 1, Tag: "mtproto-custom", Enable: true, Port: 4060, Protocol: model.MTProto,
Remark: "MTProto", Settings: `{"clients":[]}`, StreamSettings: `{}`,
ShareAddrStrategy: "custom", ShareAddr: "proxy.example.com",
}
if err := GetDB().Create(ib).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
if err := seedMtprotoCustomShareAddrToHosts(); err != nil {
t.Fatalf("migrate: %v", err)
}
var got model.Inbound
if err := GetDB().First(&got, ib.Id).Error; err != nil {
t.Fatalf("reload inbound: %v", err)
}
if got.ShareAddrStrategy != "listen" || got.ShareAddr != "" {
t.Fatalf("share fields = (%q, %q), want (listen, empty)", got.ShareAddrStrategy, got.ShareAddr)
}
var hosts []model.Host
if err := GetDB().Where("inbound_id = ?", ib.Id).Find(&hosts).Error; err != nil {
t.Fatalf("load hosts: %v", err)
}
if len(hosts) != 1 || hosts[0].Remark != "proxy.example.com" || hosts[0].Address != "proxy.example.com" || hosts[0].Port != 0 || hosts[0].Security != "same" {
t.Fatalf("migrated hosts = %+v", hosts)
}
if hosts[0].GroupId == "" {
t.Fatal("migrated host has an empty group id")
}
if err := seedMtprotoCustomShareAddrToHosts(); err != nil {
t.Fatalf("second migrate: %v", err)
}
var count int64
if err := GetDB().Model(&model.Host{}).Where("inbound_id = ?", ib.Id).Count(&count).Error; err != nil {
t.Fatalf("count hosts: %v", err)
}
if count != 1 {
t.Fatalf("host count = %d, want 1", count)
}
}
func TestMigrateMtprotoCustomShareAddrWithUnrelatedHost(t *testing.T) {
initMigrateDB(t)
ib := &model.Inbound{
UserId: 1, Tag: "mtproto-host", Enable: true, Port: 4060, Protocol: model.MTProto,
Remark: "MTProto", Settings: `{"clients":[]}`, StreamSettings: `{}`,
ShareAddrStrategy: "custom", ShareAddr: "old.example.com",
}
if err := GetDB().Create(ib).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
existing := &model.Host{
GroupId: "existing", InboundId: ib.Id, Remark: "public",
Address: "new.example.com", Port: 443, Security: "same", IsDisabled: true,
}
if err := GetDB().Create(existing).Error; err != nil {
t.Fatalf("create host: %v", err)
}
if err := seedMtprotoCustomShareAddrToHosts(); err != nil {
t.Fatalf("migrate: %v", err)
}
var hosts []model.Host
if err := GetDB().Where("inbound_id = ?", ib.Id).Order("id asc").Find(&hosts).Error; err != nil {
t.Fatalf("load hosts: %v", err)
}
if len(hosts) != 2 || hosts[0].Id != existing.Id {
t.Fatalf("hosts = %+v, want the existing host plus the migrated one", hosts)
}
if hosts[1].Address != "old.example.com" || hosts[1].Port != 0 || hosts[1].IsDisabled {
t.Fatalf("migrated host = %+v, want enabled old.example.com on the inbound port", hosts[1])
}
var got model.Inbound
if err := GetDB().First(&got, ib.Id).Error; err != nil {
t.Fatalf("reload inbound: %v", err)
}
if got.ShareAddrStrategy != "listen" || got.ShareAddr != "" {
t.Fatalf("share fields = (%q, %q), want (listen, empty)", got.ShareAddrStrategy, got.ShareAddr)
}
}
func TestMigrateMtprotoCustomShareAddrSkipsHostWithSameAddress(t *testing.T) {
initMigrateDB(t)
ib := &model.Inbound{
UserId: 1, Tag: "mtproto-dup", Enable: true, Port: 4060, Protocol: model.MTProto,
Remark: "MTProto", Settings: `{"clients":[]}`, StreamSettings: `{}`,
ShareAddrStrategy: "custom", ShareAddr: "proxy.example.com",
}
if err := GetDB().Create(ib).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
existing := &model.Host{
GroupId: "existing", InboundId: ib.Id, Remark: "public",
Address: "proxy.example.com", Port: 443, Security: "same",
}
if err := GetDB().Create(existing).Error; err != nil {
t.Fatalf("create host: %v", err)
}
if err := seedMtprotoCustomShareAddrToHosts(); err != nil {
t.Fatalf("migrate: %v", err)
}
var hosts []model.Host
if err := GetDB().Where("inbound_id = ?", ib.Id).Find(&hosts).Error; err != nil {
t.Fatalf("load hosts: %v", err)
}
if len(hosts) != 1 || hosts[0].Id != existing.Id {
t.Fatalf("hosts = %+v, want only the existing host %d", hosts, existing.Id)
}
}
func TestMigrateMtprotoCustomShareAddrWithUnusableSameAddressHost(t *testing.T) {
cases := []struct {
name string
existing model.Host
}{
{"disabled", model.Host{IsDisabled: true}},
{"excludes_raw", model.Host{ExcludeFromSubTypes: []string{"raw"}}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
initMigrateDB(t)
ib := &model.Inbound{
UserId: 1, Tag: "mtproto-" + c.name, Enable: true, Port: 4060, Protocol: model.MTProto,
Remark: "MTProto", Settings: `{"clients":[]}`, StreamSettings: `{}`,
ShareAddrStrategy: "custom", ShareAddr: "proxy.example.com",
}
if err := GetDB().Create(ib).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
existing := c.existing
existing.GroupId = "existing"
existing.InboundId = ib.Id
existing.Remark = "parked"
existing.Address = "proxy.example.com"
existing.Security = "same"
if err := GetDB().Create(&existing).Error; err != nil {
t.Fatalf("create host: %v", err)
}
if err := seedMtprotoCustomShareAddrToHosts(); err != nil {
t.Fatalf("migrate: %v", err)
}
var hosts []model.Host
if err := GetDB().Where("inbound_id = ?", ib.Id).Order("id asc").Find(&hosts).Error; err != nil {
t.Fatalf("load hosts: %v", err)
}
if len(hosts) != 2 {
t.Fatalf("hosts = %+v, want the parked host plus a usable one", hosts)
}
migrated := hosts[1]
if migrated.Address != "proxy.example.com" || migrated.IsDisabled || len(migrated.ExcludeFromSubTypes) != 0 {
t.Fatalf("migrated host = %+v, want an enabled raw-included proxy.example.com", migrated)
}
})
}
}