diff --git a/internal/database/db.go b/internal/database/db.go index 1d33c8fd9..02eef7177 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -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 diff --git a/internal/database/inbound_port_unique_migration_test.go b/internal/database/inbound_port_unique_migration_test.go new file mode 100644 index 000000000..d2de77de1 --- /dev/null +++ b/internal/database/inbound_port_unique_migration_test.go @@ -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) + } +}