Files
3x-ui/database/db.go
MHSanaei b36e5e0869 fix(security): redact at source and cap marshal sizes for CodeQL
CodeQL kept flagging the merge logger because taint flowed Password ->
ClientMergeConflict.Old -> log even with a runtime redact helper -- the
analyzer can't prove the branch excludes credentials. Redact at the
source instead: uuid/password/auth/subId now only ever land in the
conflict struct as <redacted> placeholders, so no caller (log or
otherwise) can leak them.

For the ClientWithAttachments marshal overflow alert, replace the
MaxInt-len() arithmetic with explicit per-input size caps (256MB each),
which is the pattern CodeQL's own docs recommend and recognizes.
2026-05-19 12:48:01 +02:00

447 lines
11 KiB
Go

// 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"
"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
}
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
}
blob, err := json.Marshal(obj)
if err != nil {
continue
}
var c model.Client
if err := json.Unmarshal(blob, &c); err != nil {
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
}