// Package database provides database initialization, migration, and management utilities // for the 3x-ui panel using GORM with SQLite or PostgreSQL. package database import ( "bytes" "encoding/json" "errors" "io" "log" "os" "path" "slices" "strconv" "strings" "time" "github.com/mhsanaei/3x-ui/v3/config" "github.com/mhsanaei/3x-ui/v3/database/model" "github.com/mhsanaei/3x-ui/v3/util/crypto" "github.com/mhsanaei/3x-ui/v3/xray" "gorm.io/driver/postgres" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" ) var db *gorm.DB const ( DialectSQLite = "sqlite" DialectPostgres = "postgres" ) // IsPostgres reports whether the active connection is a PostgreSQL backend. func IsPostgres() bool { if db == nil { return config.GetDBKind() == "postgres" } return db.Dialector.Name() == "postgres" } // Dialect returns the active GORM dialect name, or "" if the DB is not open. func Dialect() string { if db == nil { return "" } return db.Dialector.Name() } const ( defaultUsername = "admin" defaultPassword = "admin" ) func initModels() error { models := []any{ &model.User{}, &model.Inbound{}, &model.OutboundTraffics{}, &model.Setting{}, &model.InboundClientIps{}, &xray.ClientTraffic{}, &model.HistoryOfSeeders{}, &model.CustomGeoResource{}, &model.Node{}, &model.ApiToken{}, &model.ClientRecord{}, &model.ClientInbound{}, &model.InboundFallback{}, } for _, mdl := range models { if err := db.AutoMigrate(mdl); err != nil { if isIgnorableDuplicateColumnErr(err, mdl) { log.Printf("Ignoring duplicate column during auto migration for %T: %v", mdl, err) continue } log.Printf("Error auto migrating model: %v", err) return err } } return nil } func isIgnorableDuplicateColumnErr(err error, mdl any) bool { if err == nil { return false } errMsg := strings.ToLower(err.Error()) // SQLite: "duplicate column name: foo" // Postgres: `pq: column "foo" of relation "bar" already exists` / `sqlstate 42701` const sqlitePrefix = "duplicate column name:" if _, after, ok := strings.Cut(errMsg, sqlitePrefix); ok { col := strings.TrimSpace(after) col = strings.Trim(col, "`\"[]") return col != "" && db != nil && db.Migrator().HasColumn(mdl, col) } if strings.Contains(errMsg, "already exists") && strings.Contains(errMsg, "column ") { // Best effort: extract the column name between the first pair of double quotes. if _, after, ok := strings.Cut(errMsg, "column \""); ok { rest := after if e := strings.Index(rest, "\""); e > 0 { col := rest[:e] return col != "" && db != nil && db.Migrator().HasColumn(mdl, col) } } } return false } // initUser creates a default admin user if the users table is empty. func initUser() error { empty, err := isTableEmpty("users") if err != nil { log.Printf("Error checking if users table is empty: %v", err) return err } if empty { hashedPassword, err := crypto.HashPasswordAsBcrypt(defaultPassword) if err != nil { log.Printf("Error hashing default password: %v", err) return err } user := &model.User{ Username: defaultUsername, Password: hashedPassword, } return db.Create(user).Error } return nil } // runSeeders migrates user passwords to bcrypt and records seeder execution to prevent re-running. func runSeeders(isUsersEmpty bool) error { empty, err := isTableEmpty("history_of_seeders") if err != nil { log.Printf("Error checking if users table is empty: %v", err) return err } if empty && isUsersEmpty { hashSeeder := &model.HistoryOfSeeders{ SeederName: "UserPasswordHash", } if err := db.Create(hashSeeder).Error; err != nil { return err } return seedApiTokens() } var seedersHistory []string if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &seedersHistory).Error; err != nil { log.Printf("Error fetching seeder history: %v", err) return err } if !slices.Contains(seedersHistory, "UserPasswordHash") && !isUsersEmpty { var users []model.User if err := db.Find(&users).Error; err != nil { log.Printf("Error fetching users for password migration: %v", err) return err } for _, user := range users { hashedPassword, err := crypto.HashPasswordAsBcrypt(user.Password) if err != nil { log.Printf("Error hashing password for user '%s': %v", user.Username, err) return err } if err := db.Model(&user).Update("password", hashedPassword).Error; err != nil { log.Printf("Error updating password for user '%s': %v", user.Username, err) return err } } hashSeeder := &model.HistoryOfSeeders{ SeederName: "UserPasswordHash", } if err := db.Create(hashSeeder).Error; err != nil { return err } } if !slices.Contains(seedersHistory, "ApiTokensTable") { if err := seedApiTokens(); err != nil { return err } } if !slices.Contains(seedersHistory, "ClientsTable") { if err := seedClientsFromInboundJSON(); err != nil { return err } } return nil } // normalizeClientJSONFields coerces loosely-typed numeric fields in a raw // settings.clients entry so json.Unmarshal into model.Client doesn't fail // when older rows wrote tgId/limitIp/totalGB/etc. as strings. Empty strings // drop the key so the field falls back to its zero value. func normalizeClientJSONFields(obj map[string]any) { normalizeInt := func(key string) { raw, exists := obj[key] if !exists { return } s, ok := raw.(string) if !ok { return } trimmed := strings.ReplaceAll(strings.TrimSpace(s), " ", "") if trimmed == "" { delete(obj, key) return } if n, err := strconv.ParseInt(trimmed, 10, 64); err == nil { obj[key] = n } else { delete(obj, key) } } for _, k := range []string{"tgId", "limitIp", "totalGB", "expiryTime", "reset", "created_at", "updated_at"} { normalizeInt(k) } } func seedClientsFromInboundJSON() error { var inbounds []model.Inbound if err := db.Find(&inbounds).Error; err != nil { return err } return db.Transaction(func(tx *gorm.DB) error { byEmail := map[string]*model.ClientRecord{} for _, inbound := range inbounds { if strings.TrimSpace(inbound.Settings) == "" { continue } var settings map[string]any if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil { log.Printf("ClientsTable seed: skip inbound %d (invalid settings json): %v", inbound.Id, err) continue } rawList, ok := settings["clients"].([]any) if !ok { continue } for _, raw := range rawList { obj, ok := raw.(map[string]any) if !ok { continue } normalizeClientJSONFields(obj) blob, err := json.Marshal(obj) if err != nil { continue } var c model.Client if err := json.Unmarshal(blob, &c); err != nil { log.Printf("ClientsTable seed: skip client in inbound %d (unmarshal failed): %v; payload=%s", inbound.Id, err, string(blob)) continue } email := strings.TrimSpace(c.Email) if email == "" { continue } incoming := c.ToRecord() row, dup := byEmail[email] if !dup { if err := tx.Create(incoming).Error; err != nil { return err } byEmail[email] = incoming row = incoming } else { conflicts := model.MergeClientRecord(row, incoming) for _, x := range conflicts { log.Printf("client merge: email=%s conflict on %s old=%v new=%v kept=%v", email, x.Field, x.Old, x.New, x.Kept) } if err := tx.Save(row).Error; err != nil { return err } } link := model.ClientInbound{ ClientId: row.Id, InboundId: inbound.Id, FlowOverride: c.Flow, } if err := tx.Where("client_id = ? AND inbound_id = ?", row.Id, inbound.Id). FirstOrCreate(&link).Error; err != nil { return err } } } return tx.Create(&model.HistoryOfSeeders{SeederName: "ClientsTable"}).Error }) } // seedApiTokens copies the legacy `apiToken` setting into the new // api_tokens table as a row named "default" so existing central panels // keep working after the upgrade. Idempotent — records itself in // history_of_seeders and only runs when api_tokens is empty. func seedApiTokens() error { empty, err := isTableEmpty("api_tokens") if err != nil { return err } if empty { var legacy model.Setting err := db.Model(model.Setting{}).Where("key = ?", "apiToken").First(&legacy).Error if err == nil && legacy.Value != "" { row := &model.ApiToken{ Name: "default", Token: legacy.Value, Enabled: true, } if err := db.Create(row).Error; err != nil { log.Printf("Error migrating legacy apiToken: %v", err) return err } } } return db.Create(&model.HistoryOfSeeders{SeederName: "ApiTokensTable"}).Error } // isTableEmpty returns true if the named table contains zero rows. func isTableEmpty(tableName string) (bool, error) { var count int64 err := db.Table(tableName).Count(&count).Error return count == 0, err } // InitDB sets up the database connection, migrates models, and runs seeders. // When XUI_DB_TYPE=postgres, dbPath is ignored and XUI_DB_DSN is used instead. func InitDB(dbPath string) error { var gormLogger logger.Interface if config.IsDebug() { gormLogger = logger.Default } else { gormLogger = logger.Discard } c := &gorm.Config{Logger: gormLogger} var err error switch config.GetDBKind() { case "postgres": dsn := config.GetDBDSN() if dsn == "" { return errors.New("XUI_DB_TYPE=postgres but XUI_DB_DSN is empty") } db, err = gorm.Open(postgres.Open(dsn), c) if err != nil { return err } default: dir := path.Dir(dbPath) if err = os.MkdirAll(dir, 0755); err != nil { return err } dsn := dbPath + "?_journal_mode=WAL&_busy_timeout=10000&_synchronous=NORMAL&_txlock=immediate" db, err = gorm.Open(sqlite.Open(dsn), c) if err != nil { return err } sqlDB, err := db.DB() if err != nil { return err } if _, err := sqlDB.Exec("PRAGMA journal_mode=WAL"); err != nil { return err } if _, err := sqlDB.Exec("PRAGMA busy_timeout=10000"); err != nil { return err } if _, err := sqlDB.Exec("PRAGMA synchronous=NORMAL"); err != nil { return err } } sqlDB, err := db.DB() if err != nil { return err } sqlDB.SetMaxOpenConns(8) sqlDB.SetMaxIdleConns(4) sqlDB.SetConnMaxLifetime(time.Hour) if err := initModels(); err != nil { return err } isUsersEmpty, err := isTableEmpty("users") if err != nil { return err } if err := initUser(); err != nil { return err } return runSeeders(isUsersEmpty) } // CloseDB closes the database connection if it exists. func CloseDB() error { if db != nil { sqlDB, err := db.DB() if err != nil { return err } return sqlDB.Close() } return nil } // GetDB returns the global GORM database instance. func GetDB() *gorm.DB { return db } func IsNotFound(err error) bool { return errors.Is(err, gorm.ErrRecordNotFound) } // IsSQLiteDB checks if the given file is a valid SQLite database by reading its signature. func IsSQLiteDB(file io.ReaderAt) (bool, error) { signature := []byte("SQLite format 3\x00") buf := make([]byte, len(signature)) _, err := file.ReadAt(buf, 0) if err != nil { return false, err } return bytes.Equal(buf, signature), nil } // Checkpoint performs a WAL checkpoint on the SQLite database to ensure data consistency. // No-op on PostgreSQL (WAL there is managed by the server). func Checkpoint() error { if IsPostgres() { return nil } return db.Exec("PRAGMA wal_checkpoint;").Error } // ValidateSQLiteDB opens the provided sqlite DB path with a throw-away connection // and runs a PRAGMA integrity_check to ensure the file is structurally sound. // It does not mutate global state or run migrations. func ValidateSQLiteDB(dbPath string) error { if _, err := os.Stat(dbPath); err != nil { // file must exist return err } gdb, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{Logger: logger.Discard}) if err != nil { return err } sqlDB, err := gdb.DB() if err != nil { return err } defer sqlDB.Close() var res string if err := gdb.Raw("PRAGMA integrity_check;").Scan(&res).Error; err != nil { return err } if res != "ok" { return errors.New("sqlite integrity check failed: " + res) } return nil }