3x-ui/database/db.go

369 lines
8.7 KiB
Go
Raw Normal View History

2025-09-20 07:35:50 +00:00
// Package database provides database initialization, migration, and management utilities
// for the 3x-ui panel using GORM with SQLite or MariaDB.
2023-02-09 19:18:06 +00:00
package database
import (
2023-05-05 18:21:39 +00:00
"bytes"
"errors"
2023-05-05 18:21:39 +00:00
"io"
2023-02-09 19:18:06 +00:00
"io/fs"
2024-07-13 23:22:02 +00:00
"log"
2023-02-09 19:18:06 +00:00
"os"
"path"
"slices"
2025-09-19 08:05:43 +00:00
"github.com/mhsanaei/3x-ui/v2/config"
"github.com/mhsanaei/3x-ui/v2/database/model"
"github.com/mhsanaei/3x-ui/v2/util/crypto"
"github.com/mhsanaei/3x-ui/v2/xray"
2023-02-16 15:58:20 +00:00
mysql2 "github.com/go-sql-driver/mysql"
"gorm.io/driver/mysql"
2023-02-16 15:58:20 +00:00
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
2023-02-09 19:18:06 +00:00
)
var db *gorm.DB
2024-07-13 23:22:02 +00:00
const (
defaultUsername = "admin"
defaultPassword = "admin"
)
func initModels() error {
models := []any{
2024-07-13 23:22:02 +00:00
&model.User{},
&model.Inbound{},
&model.OutboundTraffics{},
&model.Setting{},
2024-07-13 23:22:02 +00:00
&model.InboundClientIps{},
&xray.ClientTraffic{},
&model.HistoryOfSeeders{},
&model.SharedState{},
&model.NodeState{},
2024-07-13 23:22:02 +00:00
}
for _, model := range models {
2024-07-13 23:22:02 +00:00
if err := db.AutoMigrate(model); err != nil {
log.Printf("Error auto migrating model: %v", err)
2024-07-13 23:22:02 +00:00
return err
}
}
if err := seedSharedAccountsVersion(db); err != nil {
return err
}
2024-07-13 23:22:02 +00:00
return nil
2023-05-22 23:13:15 +00:00
}
2025-09-20 07:35:50 +00:00
// initUser creates a default admin user if the users table is empty.
2023-02-09 19:18:06 +00:00
func initUser() error {
2024-07-13 23:22:02 +00:00
empty, err := isTableEmpty("users")
2023-02-09 19:18:06 +00:00
if err != nil {
2024-07-13 23:22:02 +00:00
log.Printf("Error checking if users table is empty: %v", err)
2023-02-09 19:18:06 +00:00
return err
}
2024-07-13 23:22:02 +00:00
if empty {
hashedPassword, err := crypto.HashPasswordAsBcrypt(defaultPassword)
if err != nil {
log.Printf("Error hashing default password: %v", err)
return err
}
2023-02-09 19:18:06 +00:00
user := &model.User{
Username: defaultUsername,
Password: hashedPassword,
Role: "admin",
2023-02-09 19:18:06 +00:00
}
if err := db.Create(user).Error; err != nil {
return err
}
// Mark password hashing seeder as done since initUser already uses bcrypt
hashSeeder := &model.HistoryOfSeeders{
SeederName: "UserPasswordHash",
}
return db.Create(hashSeeder).Error
2023-02-09 19:18:06 +00:00
}
return nil
}
2025-09-20 07:35:50 +00:00
// 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
}
return db.Transaction(func(tx *gorm.DB) error {
if empty && isUsersEmpty {
hashSeeder := &model.HistoryOfSeeders{
SeederName: "UserPasswordHash",
}
return tx.Create(hashSeeder).Error
}
var seedersHistory []string
if err := tx.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &seedersHistory).Error; err != nil {
return err
}
if !slices.Contains(seedersHistory, "UserPasswordHash") && !isUsersEmpty {
var users []model.User
if err := tx.Find(&users).Error; err != nil {
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 := tx.Model(&user).Update("password", hashedPassword).Error; err != nil {
return err
}
}
hashSeeder := &model.HistoryOfSeeders{
SeederName: "UserPasswordHash",
}
if err := tx.Create(hashSeeder).Error; err != nil {
return err
}
}
if !slices.Contains(seedersHistory, "RemoveClientTrafficEmailUnique") {
// Drop the old unique index on client_traffics.email to allow
// the same email across multiple inbounds
dbType := config.GetDBTypeFromJSON()
var execErr error
if dbType == "mariadb" {
execErr = tx.Exec("DROP INDEX IF EXISTS idx_client_traffics_email ON client_traffics").Error
} else {
execErr = tx.Exec("DROP INDEX IF EXISTS idx_client_traffics_email").Error
}
if execErr != nil {
return execErr
}
uniqueSeeder := &model.HistoryOfSeeders{
SeederName: "RemoveClientTrafficEmailUnique",
}
if err := tx.Create(uniqueSeeder).Error; err != nil {
return err
}
}
return nil
})
}
2025-09-20 07:35:50 +00:00
// isTableEmpty returns true if the named table contains zero rows.
2024-07-13 23:22:02 +00:00
func isTableEmpty(tableName string) (bool, error) {
var count int64
err := db.Table(tableName).Count(&count).Error
return count == 0, err
2023-02-09 19:18:06 +00:00
}
2025-09-20 07:35:50 +00:00
// InitDB sets up the database connection, migrates models, and runs seeders.
// It reads the dbType from the JSON config to determine whether to use SQLite or MariaDB.
func InitDB() error {
CloseDB() // close any existing connection before re-initializing
dbType := config.GetDBTypeFromJSON()
var err error
switch dbType {
case "mariadb":
err = initMariaDB()
default:
err = initSQLite(config.GetDBPath())
}
if err != nil {
return err
}
if err := initModels(); err != nil {
return err
}
if err := initUser(); err != nil {
return err
}
isUsersEmpty, err := isTableEmpty("users")
if err != nil {
return err
}
return runSeeders(isUsersEmpty)
}
// InitDBWithPath is a convenience function for tests and migrations that need
// to open a specific SQLite file.
func InitDBWithPath(dbPath string) error {
CloseDB() // close any existing connection before re-initializing
if err := initSQLite(dbPath); err != nil {
return err
}
if err := initModels(); err != nil {
return err
}
if err := initUser(); err != nil {
return err
}
isUsersEmpty, err := isTableEmpty("users")
if err != nil {
return err
}
return runSeeders(isUsersEmpty)
}
// initSQLite opens a SQLite database connection and runs model migrations.
func initSQLite(dbPath string) error {
2023-02-09 19:18:06 +00:00
dir := path.Dir(dbPath)
2023-05-22 23:13:15 +00:00
err := os.MkdirAll(dir, fs.ModePerm)
2023-02-09 19:18:06 +00:00
if err != nil {
return err
}
var gormLogger logger.Interface
if config.IsDebug() {
gormLogger = logger.Default
} else {
gormLogger = logger.Discard
}
c := &gorm.Config{
Logger: gormLogger,
2023-02-09 19:18:06 +00:00
}
db, err = gorm.Open(sqlite.Open(dbPath), c)
2023-02-09 19:18:06 +00:00
if err != nil {
return err
}
return nil
}
// buildMariaDBDSN constructs a MariaDB DSN from the given config using
// go-sql-driver/mysql's Config to properly escape special characters in credentials.
func buildMariaDBDSN(dbConfig config.DBConfig) string {
cfg := mysql2.Config{
User: dbConfig.User,
Passwd: dbConfig.Password,
Net: "tcp",
Addr: dbConfig.Host + ":" + dbConfig.Port,
DBName: dbConfig.Name,
Params: map[string]string{
"charset": "utf8mb4",
"parseTime": "True",
"loc": "Local",
},
AllowNativePasswords: true,
}
return cfg.FormatDSN()
}
// OpenMariaDB opens a new MariaDB connection from the given config.
// Caller must close the returned db when done.
func OpenMariaDB(dbConfig config.DBConfig) (*gorm.DB, error) {
dsn := buildMariaDBDSN(dbConfig)
return gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Discard,
})
}
// initMariaDB opens a MariaDB connection and runs model migrations.
func initMariaDB() error {
dbConfig := config.GetDBConfigFromJSON()
dsn := buildMariaDBDSN(dbConfig)
var gormLogger logger.Interface
if config.IsDebug() {
gormLogger = logger.Default
} else {
gormLogger = logger.Discard
2024-07-13 23:22:02 +00:00
}
var err error
c := &gorm.Config{
Logger: gormLogger,
}
db, err = gorm.Open(mysql.Open(dsn), c)
2025-09-18 20:06:01 +00:00
if err != nil {
return err
}
return nil
2024-07-13 23:22:02 +00:00
}
2025-09-20 07:35:50 +00:00
// CloseDB closes the database connection if it exists.
2024-07-13 23:22:02 +00:00
func CloseDB() error {
if db != nil {
sqlDB, err := db.DB()
if err != nil {
2023-05-22 23:13:15 +00:00
return err
}
2024-07-13 23:22:02 +00:00
return sqlDB.Close()
2023-02-09 19:18:06 +00:00
}
return nil
}
2025-09-20 07:35:50 +00:00
// GetDB returns the global GORM database instance.
2023-02-09 19:18:06 +00:00
func GetDB() *gorm.DB {
return db
}
2025-09-20 07:35:50 +00:00
// IsNotFound checks if the given error is a GORM record not found error.
2023-02-09 19:18:06 +00:00
func IsNotFound(err error) bool {
return err == gorm.ErrRecordNotFound
}
2023-05-05 18:21:39 +00:00
2025-09-20 07:35:50 +00:00
// IsSQLiteDB checks if the given file is a valid SQLite database by reading its signature.
2023-05-22 23:13:15 +00:00
func IsSQLiteDB(file io.ReaderAt) (bool, error) {
2023-05-05 18:21:39 +00:00
signature := []byte("SQLite format 3\x00")
buf := make([]byte, len(signature))
2023-05-22 23:13:15 +00:00
_, err := file.ReadAt(buf, 0)
2023-05-05 18:21:39 +00:00
if err != nil {
return false, err
}
return bytes.Equal(buf, signature), nil
}
2023-12-08 19:35:10 +00:00
2025-09-20 07:35:50 +00:00
// Checkpoint performs a WAL checkpoint on the SQLite database to ensure data consistency.
// For MariaDB, this is a no-op.
2023-12-08 19:35:10 +00:00
func Checkpoint() error {
if config.GetDBTypeFromJSON() != "sqlite" {
return nil
2023-12-08 19:35:10 +00:00
}
return db.Exec("PRAGMA wal_checkpoint;").Error
2023-12-08 19:35:10 +00:00
}
// 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
}