| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480 |
- // 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
- }
|