3x-ui/database/db.go

413 lines
9.3 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 PostgreSQL.
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"
"net"
"net/url"
2023-02-09 19:18:06 +00:00
"os"
"path/filepath"
"slices"
"strconv"
"time"
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
"gorm.io/driver/postgres"
2023-02-16 15:58:20 +00:00
"gorm.io/driver/sqlite"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
2023-02-09 19:18:06 +00:00
)
var (
db *gorm.DB
dbConfig *config.DatabaseConfig
)
2023-02-09 19:18:06 +00:00
2024-07-13 23:22:02 +00:00
const (
defaultUsername = "admin"
defaultPassword = "admin"
)
func gormConfig() *gorm.Config {
var loggerImpl gormlogger.Interface
if config.IsDebug() {
loggerImpl = gormlogger.Default
} else {
loggerImpl = gormlogger.Discard
}
return &gorm.Config{Logger: loggerImpl}
}
func openSQLiteDatabase(path string) (*gorm.DB, error) {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, fs.ModePerm); err != nil {
return nil, err
}
return gorm.Open(sqlite.Open(path), gormConfig())
}
func buildPostgresDSN(cfg *config.DatabaseConfig) string {
user := url.User(cfg.Postgres.User)
if cfg.Postgres.Password != "" {
user = url.UserPassword(cfg.Postgres.User, cfg.Postgres.Password)
}
authority := net.JoinHostPort(cfg.Postgres.Host, strconv.Itoa(cfg.Postgres.Port))
u := &url.URL{
Scheme: "postgres",
User: user,
Host: authority,
Path: cfg.Postgres.DBName,
}
query := u.Query()
query.Set("sslmode", cfg.Postgres.SSLMode)
u.RawQuery = query.Encode()
return u.String()
}
func openPostgresDatabase(cfg *config.DatabaseConfig) (*gorm.DB, error) {
gormCfg := gormConfig()
gormCfg.PrepareStmt = true
conn, err := gorm.Open(postgres.Open(buildPostgresDSN(cfg)), gormCfg)
if err != nil {
return nil, err
}
sqlDB, err := conn.DB()
if err != nil {
return nil, err
}
sqlDB.SetMaxIdleConns(5)
sqlDB.SetMaxOpenConns(25)
sqlDB.SetConnMaxLifetime(30 * time.Minute)
sqlDB.SetConnMaxIdleTime(10 * time.Minute)
return conn, nil
}
// OpenDatabase opens a database connection from the provided runtime configuration.
func OpenDatabase(cfg *config.DatabaseConfig) (*gorm.DB, error) {
if cfg == nil {
cfg = config.DefaultDatabaseConfig()
}
cfg = cfg.Clone().Normalize()
switch cfg.Driver {
case config.DatabaseDriverSQLite:
return openSQLiteDatabase(cfg.SQLite.Path)
case config.DatabaseDriverPostgres:
return openPostgresDatabase(cfg)
default:
return nil, errors.New("unsupported database driver: " + cfg.Driver)
}
}
// CloseConnection closes a standalone gorm connection.
func CloseConnection(conn *gorm.DB) error {
if conn == nil {
return nil
}
sqlDB, err := conn.DB()
if err != nil {
return err
}
return sqlDB.Close()
}
func initModels(conn *gorm.DB) 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{},
2024-07-13 23:22:02 +00:00
}
for _, item := range models {
if err := conn.AutoMigrate(item); err != nil {
log.Printf("Error auto migrating model: %v", err)
2024-07-13 23:22:02 +00:00
return err
}
}
return nil
2023-05-22 23:13:15 +00:00
}
func isTableEmpty(conn *gorm.DB, tableName string) (bool, error) {
if !conn.Migrator().HasTable(tableName) {
return true, nil
}
var count int64
err := conn.Table(tableName).Count(&count).Error
return count == 0, err
}
func initUser(conn *gorm.DB) error {
empty, err := isTableEmpty(conn, "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
}
if !empty {
return nil
}
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,
2023-02-09 19:18:06 +00:00
}
return conn.Create(user).Error
2023-02-09 19:18:06 +00:00
}
func runSeeders(conn *gorm.DB, isUsersEmpty bool) error {
empty, err := isTableEmpty(conn, "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",
}
return conn.Create(hashSeeder).Error
}
var seedersHistory []string
if err := conn.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &seedersHistory).Error; err != nil {
return err
}
if slices.Contains(seedersHistory, "UserPasswordHash") || isUsersEmpty {
return nil
}
var users []model.User
if err := conn.Find(&users).Error; err != nil {
return err
}
for _, user := range users {
hashedPassword, hashErr := crypto.HashPasswordAsBcrypt(user.Password)
if hashErr != nil {
log.Printf("Error hashing password for user '%s': %v", user.Username, hashErr)
return hashErr
}
if err := conn.Model(&user).Update("password", hashedPassword).Error; err != nil {
return err
}
}
hashSeeder := &model.HistoryOfSeeders{
SeederName: "UserPasswordHash",
}
return conn.Create(hashSeeder).Error
}
// MigrateModels migrates the database schema for all panel models.
func MigrateModels(conn *gorm.DB) error {
return initModels(conn)
2023-02-09 19:18:06 +00:00
}
// PrepareDatabase migrates the schema and optionally seeds the database.
func PrepareDatabase(conn *gorm.DB, seed bool) error {
if err := initModels(conn); err != nil {
2023-02-09 19:18:06 +00:00
return err
}
if !seed {
return nil
}
2023-02-09 19:18:06 +00:00
isUsersEmpty, err := isTableEmpty(conn, "users")
if err != nil {
return err
}
2023-02-09 19:18:06 +00:00
if err := initUser(conn); err != nil {
return err
2023-02-09 19:18:06 +00:00
}
return runSeeders(conn, isUsersEmpty)
}
2023-02-09 19:18:06 +00:00
// TestConnection verifies that the provided database configuration is reachable.
func TestConnection(cfg *config.DatabaseConfig) error {
conn, err := OpenDatabase(cfg)
if err != nil {
return err
2023-02-09 19:18:06 +00:00
}
defer CloseConnection(conn)
sqlDB, err := conn.DB()
2023-02-09 19:18:06 +00:00
if err != nil {
return err
}
return sqlDB.Ping()
}
2023-02-09 19:18:06 +00:00
// InitDB sets up the database connection, migrates models, and runs seeders.
func InitDB() error {
cfg, err := config.LoadDatabaseConfig()
if err != nil {
2024-07-13 23:22:02 +00:00
return err
}
return InitDBWithConfig(cfg)
}
// InitDBWithConfig sets up the database using an explicit runtime config.
func InitDBWithConfig(cfg *config.DatabaseConfig) error {
conn, err := OpenDatabase(cfg)
2025-09-18 20:06:01 +00:00
if err != nil {
return err
}
if err := PrepareDatabase(conn, true); err != nil {
_ = CloseConnection(conn)
return err
}
if err := CloseDB(); err != nil {
_ = CloseConnection(conn)
2024-07-13 23:22:02 +00:00
return err
}
db = conn
dbConfig = cfg.Clone().Normalize()
return nil
2024-07-13 23:22:02 +00:00
}
// CloseDB closes the global database connection if it exists.
2024-07-13 23:22:02 +00:00
func CloseDB() error {
if db == nil {
dbConfig = nil
return nil
2023-02-09 19:18:06 +00:00
}
err := CloseConnection(db)
db = nil
dbConfig = nil
return err
2023-02-09 19:18:06 +00:00
}
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
}
// GetDBConfig returns a copy of the active database runtime configuration.
func GetDBConfig() *config.DatabaseConfig {
if dbConfig == nil {
return nil
}
return dbConfig.Clone()
}
// GetDriver returns the active GORM dialector name.
func GetDriver() string {
if db != nil && db.Dialector != nil {
return db.Dialector.Name()
}
if dbConfig != nil {
switch dbConfig.Driver {
case config.DatabaseDriverPostgres:
return "postgres"
case config.DatabaseDriverSQLite:
return "sqlite"
}
}
return ""
}
// IsSQLite reports whether the active database uses SQLite.
func IsSQLite() bool {
return GetDriver() == "sqlite"
}
// IsPostgres reports whether the active database uses PostgreSQL.
func IsPostgres() bool {
return GetDriver() == "postgres"
}
// IsDatabaseEmpty reports whether the provided database contains any application rows.
func IsDatabaseEmpty(conn *gorm.DB) (bool, error) {
tables := []string{
"users",
"inbounds",
"outbound_traffics",
"settings",
"inbound_client_ips",
"client_traffics",
"history_of_seeders",
}
for _, table := range tables {
empty, err := isTableEmpty(conn, table)
if err != nil {
return false, err
}
if !empty {
return false, nil
}
}
return true, nil
}
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.
2023-12-08 19:35:10 +00:00
func Checkpoint() error {
if !IsSQLite() || db == nil {
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 {
return err
}
gdb, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{Logger: gormlogger.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
}