fix(database): drop the legacy UNIQUE constraint on inbounds.port

Inbound.Port carried a unique gorm tag before multi-node existed; the
tag was removed from the model long ago, but AutoMigrate never drops a
constraint, so SQLite databases created in that era still physically
enforce global port uniqueness. On such installs the node-scoped port
conflict logic is correct yet the raw insert fails - manual "Deploy to
node" saves and setRemoteTraffic's central-inbound adoption both die
with "UNIQUE constraint failed: inbounds.port" when two nodes use the
same port.

Add a SQLite-only migration that drops an explicit unique index on port
directly, and rebuilds the table (create from the current model, copy
shared columns, swap) when the constraint is inline, since SQLite can't
ALTER it away. Fresh databases and Postgres are untouched.

Closes #5894
This commit is contained in:
MHSanaei
2026-07-11 21:21:48 +02:00
parent c4448f4ea8
commit fc625d8f66
2 changed files with 194 additions and 0 deletions
+118
View File
@@ -91,6 +91,9 @@ func initModels() error {
return err
}
}
if err := dropLegacyInboundPortUnique(); err != nil {
return err
}
if err := migrateHostVerifyPeerCertByNameColumn(); err != nil {
return err
}
@@ -160,6 +163,121 @@ func dropLegacyForeignKeys() error {
return nil
}
type sqliteIndexListRow struct {
Name string `gorm:"column:name"`
Unique int `gorm:"column:unique"`
Origin string `gorm:"column:origin"`
}
func sqliteUniquePortIndexes() (autoIndexes, explicitIndexes []string, err error) {
var list []sqliteIndexListRow
if err = db.Raw(`PRAGMA index_list('inbounds')`).Scan(&list).Error; err != nil {
return nil, nil, err
}
for _, idx := range list {
if idx.Unique != 1 {
continue
}
var cols []struct {
Name string `gorm:"column:name"`
}
if err = db.Raw(`PRAGMA index_info("` + idx.Name + `")`).Scan(&cols).Error; err != nil {
return nil, nil, err
}
if len(cols) != 1 || cols[0].Name != "port" {
continue
}
if idx.Origin == "c" {
explicitIndexes = append(explicitIndexes, idx.Name)
} else {
autoIndexes = append(autoIndexes, idx.Name)
}
}
return autoIndexes, explicitIndexes, nil
}
// dropLegacyInboundPortUnique removes the pre-multi-node UNIQUE on inbounds.port,
// which AutoMigrate never drops and which blocks cross-node port reuse on old SQLite DBs.
func dropLegacyInboundPortUnique() error {
if IsPostgres() {
return nil
}
autoIndexes, explicitIndexes, err := sqliteUniquePortIndexes()
if err != nil {
return err
}
for _, name := range explicitIndexes {
if err := db.Exec(`DROP INDEX IF EXISTS "` + name + `"`).Error; err != nil {
return err
}
}
if len(autoIndexes) == 0 {
return nil
}
log.Printf("Rebuilding inbounds table to drop the legacy UNIQUE constraint on port")
return rebuildInboundsWithoutInlineUniquePort()
}
func sqliteTableColumns(tx *gorm.DB, table string) ([]string, error) {
var rows []struct {
Name string `gorm:"column:name"`
}
if err := tx.Raw(`PRAGMA table_info("` + table + `")`).Scan(&rows).Error; err != nil {
return nil, err
}
cols := make([]string, 0, len(rows))
for _, r := range rows {
cols = append(cols, r.Name)
}
return cols, nil
}
func rebuildInboundsWithoutInlineUniquePort() error {
return db.Transaction(func(tx *gorm.DB) error {
var list []sqliteIndexListRow
if err := tx.Raw(`PRAGMA index_list('inbounds')`).Scan(&list).Error; err != nil {
return err
}
for _, idx := range list {
if idx.Origin != "c" {
continue
}
if err := tx.Exec(`DROP INDEX IF EXISTS "` + idx.Name + `"`).Error; err != nil {
return err
}
}
if err := tx.Exec(`ALTER TABLE inbounds RENAME TO inbounds_legacy_rebuild`).Error; err != nil {
return err
}
if err := tx.Migrator().CreateTable(&model.Inbound{}); err != nil {
return err
}
newCols, err := sqliteTableColumns(tx, "inbounds")
if err != nil {
return err
}
oldCols, err := sqliteTableColumns(tx, "inbounds_legacy_rebuild")
if err != nil {
return err
}
oldSet := make(map[string]struct{}, len(oldCols))
for _, c := range oldCols {
oldSet[c] = struct{}{}
}
shared := make([]string, 0, len(newCols))
for _, c := range newCols {
if _, ok := oldSet[c]; ok {
shared = append(shared, `"`+c+`"`)
}
}
colList := strings.Join(shared, ", ")
if err := tx.Exec(`INSERT INTO inbounds (` + colList + `) SELECT ` + colList + ` FROM inbounds_legacy_rebuild`).Error; err != nil {
return err
}
return tx.Exec(`DROP TABLE inbounds_legacy_rebuild`).Error
})
}
func migrateHostVerifyPeerCertByNameColumn() error {
if !db.Migrator().HasColumn(&model.Host{}, "verify_peer_cert_by_name") {
return nil
@@ -0,0 +1,76 @@
package database
import (
"path/filepath"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
const legacyInboundsDDL = "CREATE TABLE `inbounds` (`id` integer PRIMARY KEY AUTOINCREMENT,`user_id` integer,`up` integer,`down` integer,`total` integer,`remark` text,`enable` numeric,`expiry_time` integer,`listen` text,`port` integer UNIQUE,`protocol` text,`settings` text,`stream_settings` text,`tag` text UNIQUE,`sniffing` text)"
func openLegacyPortUniqueDB(t *testing.T) string {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "x-ui.db")
legacy, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
if err != nil {
t.Fatalf("open legacy db: %v", err)
}
if err := legacy.Exec(legacyInboundsDDL).Error; err != nil {
t.Fatalf("create legacy inbounds: %v", err)
}
if err := legacy.Exec(
`INSERT INTO inbounds (user_id, remark, enable, port, protocol, settings, stream_settings, tag, sniffing)
VALUES (1, 'preexisting', 1, 80, 'vless', '{"clients":[]}', '{}', 'in-80-tcp', '{}')`,
).Error; err != nil {
t.Fatalf("seed legacy inbound: %v", err)
}
sqlDB, err := legacy.DB()
if err != nil {
t.Fatalf("legacy db handle: %v", err)
}
if err := sqlDB.Close(); err != nil {
t.Fatalf("close legacy db: %v", err)
}
return dbPath
}
func TestDropLegacyInboundPortUnique(t *testing.T) {
dbPath := openLegacyPortUniqueDB(t)
if err := InitDB(dbPath); err != nil {
t.Fatalf("InitDB over legacy schema: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
var preexisting model.Inbound
if err := GetDB().Where("tag = ?", "in-80-tcp").First(&preexisting).Error; err != nil {
t.Fatalf("preexisting inbound lost in rebuild: %v", err)
}
if preexisting.Remark != "preexisting" || preexisting.Port != 80 {
t.Fatalf("preexisting inbound corrupted: remark=%q port=%d", preexisting.Remark, preexisting.Port)
}
nodeA, nodeB := 1, 2
samePortA := &model.Inbound{UserId: 1, Tag: "node-a-80", Enable: true, Port: 80, Protocol: model.VLESS, Remark: "a", Settings: `{"clients":[]}`, NodeID: &nodeA}
samePortB := &model.Inbound{UserId: 1, Tag: "node-b-80", Enable: true, Port: 80, Protocol: model.VLESS, Remark: "b", Settings: `{"clients":[]}`, NodeID: &nodeB}
if err := GetDB().Create(samePortA).Error; err != nil {
t.Fatalf("create node-a inbound on port 80: %v", err)
}
if err := GetDB().Create(samePortB).Error; err != nil {
t.Fatalf("create node-b inbound on port 80: %v", err)
}
var dupTag model.Inbound
dupErr := GetDB().Create(&model.Inbound{UserId: 1, Tag: "in-80-tcp", Enable: true, Port: 90, Protocol: model.VLESS, Settings: `{"clients":[]}`}).Error
if dupErr == nil {
t.Fatalf("duplicate tag insert succeeded, want unique violation; row=%v", dupTag)
}
if err := dropLegacyInboundPortUnique(); err != nil {
t.Fatalf("second dropLegacyInboundPortUnique run: %v", err)
}
}