Auth user lookup retries briefly on DB lock; SQLite opens with busy_timeout and a single connection. Dashboard tracks TCP/UDP series and draws them as solid/dashed lines like traffic rates.
166 lines
5.1 KiB
Go
166 lines
5.1 KiB
Go
package database
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
)
|
|
|
|
var GlobalDB *gorm.DB
|
|
|
|
func InitDB(dbPath string) (*gorm.DB, error) {
|
|
dir := filepath.Dir(dbPath)
|
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
|
return nil, fmt.Errorf("failed to create database directory: %w", err)
|
|
}
|
|
// The database contains password hashes, proxy credentials and subscription
|
|
// tokens. Do not leave it readable by other local users.
|
|
_ = os.Chmod(dir, 0700)
|
|
|
|
dsn := dbPath
|
|
if dsn != "" && dsn != ":memory:" {
|
|
if strings.Contains(dsn, "?") {
|
|
dsn += "&_busy_timeout=8000"
|
|
} else {
|
|
dsn += "?_busy_timeout=8000"
|
|
}
|
|
}
|
|
db, err := gorm.Open(sqlite.New(sqlite.Config{DriverName: sqliteDriverName, DSN: dsn}), &gorm.Config{
|
|
// Info logs every ErrRecordNotFound (e.g. optional panel_settings keys).
|
|
// Warn keeps real SQL failures without flooding journald every tick.
|
|
Logger: logger.Default.LogMode(logger.Warn),
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to connect to sqlite database: %w", err)
|
|
}
|
|
if sqlDB, sqErr := db.DB(); sqErr == nil {
|
|
// Serialize SQLite access; wait up to 8s when config apply holds the lock.
|
|
sqlDB.SetMaxOpenConns(1)
|
|
sqlDB.SetMaxIdleConns(1)
|
|
_, _ = sqlDB.Exec("PRAGMA busy_timeout = 8000")
|
|
}
|
|
// Best-effort: file may not exist yet on brand-new installs until first write.
|
|
if _, statErr := os.Stat(dbPath); statErr == nil {
|
|
_ = os.Chmod(dbPath, 0600)
|
|
}
|
|
|
|
// Resolve collisions (including soft-deleted rows) before unique indexes.
|
|
if err := dedupeListenerNames(db); err != nil {
|
|
return nil, fmt.Errorf("dedupe listener names: %w", err)
|
|
}
|
|
if err := ensureUniqueSubTokens(db); err != nil {
|
|
return nil, fmt.Errorf("ensure unique sub tokens: %w", err)
|
|
}
|
|
|
|
err = db.AutoMigrate(
|
|
&models.User{}, &models.Listener{}, &models.ListenerUser{}, &models.ListenerVersion{},
|
|
&models.ListenerTemplate{}, &models.Subscription{}, &models.AccessToken{}, &models.Config{},
|
|
&models.ProxyUser{}, &models.UserNodeTraffic{}, &models.TrafficRecord{}, &models.PanelSetting{}, &models.RemoteServer{}, &models.RemoteNodeMirror{}, &models.ProxyUserRemoteNode{},
|
|
)
|
|
if err != nil {
|
|
// One more cleanup pass then retry — soft-deleted collisions are the usual cause.
|
|
_ = dedupeListenerNames(db)
|
|
_ = ensureUniqueSubTokens(db)
|
|
if retryErr := db.AutoMigrate(
|
|
&models.User{}, &models.Listener{}, &models.ListenerUser{}, &models.ListenerVersion{},
|
|
&models.ListenerTemplate{}, &models.Subscription{}, &models.AccessToken{}, &models.Config{},
|
|
&models.ProxyUser{}, &models.UserNodeTraffic{}, &models.TrafficRecord{}, &models.PanelSetting{}, &models.RemoteServer{}, &models.RemoteNodeMirror{}, &models.ProxyUserRemoteNode{},
|
|
); retryErr != nil {
|
|
return nil, fmt.Errorf("failed to run database auto-migration: %w (after dedupe retry)", retryErr)
|
|
}
|
|
}
|
|
|
|
GlobalDB = db
|
|
return db, nil
|
|
}
|
|
|
|
// dedupeListenerNames renames later duplicates (including soft-deleted) so
|
|
// AutoMigrate can create uniqueIndex on name without failing panel boot.
|
|
func dedupeListenerNames(db *gorm.DB) error {
|
|
if !db.Migrator().HasTable(&models.Listener{}) {
|
|
return nil
|
|
}
|
|
var rows []models.Listener
|
|
// Must include soft-deleted rows: UNIQUE indexes cover the whole table.
|
|
if err := db.Unscoped().Order("id asc").Find(&rows).Error; err != nil {
|
|
return err
|
|
}
|
|
seen := map[string]uint{}
|
|
for _, row := range rows {
|
|
name := row.Name
|
|
if name == "" {
|
|
name = fmt.Sprintf("listener-%d", row.ID)
|
|
if err := db.Unscoped().Model(&models.Listener{}).Where("id = ?", row.ID).Update("name", name).Error; err != nil {
|
|
return err
|
|
}
|
|
seen[name] = row.ID
|
|
continue
|
|
}
|
|
if prev, ok := seen[name]; ok && prev != row.ID {
|
|
newName := fmt.Sprintf("%s-%d", name, row.ID)
|
|
if err := db.Unscoped().Model(&models.Listener{}).Where("id = ?", row.ID).Update("name", newName).Error; err != nil {
|
|
return err
|
|
}
|
|
seen[newName] = row.ID
|
|
continue
|
|
}
|
|
seen[name] = row.ID
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ensureUniqueSubTokens fills empty tokens and renames duplicate tokens across
|
|
// all rows (including soft-deleted) so uniqueIndex on sub_token can be created.
|
|
func ensureUniqueSubTokens(db *gorm.DB) error {
|
|
if !db.Migrator().HasTable(&models.ProxyUser{}) {
|
|
return nil
|
|
}
|
|
var rows []models.ProxyUser
|
|
if err := db.Unscoped().Order("id asc").Find(&rows).Error; err != nil {
|
|
return err
|
|
}
|
|
seen := map[string]uint{}
|
|
for _, row := range rows {
|
|
tok := row.SubToken
|
|
needNew := tok == ""
|
|
if !needNew {
|
|
if prev, ok := seen[tok]; ok && prev != row.ID {
|
|
needNew = true
|
|
}
|
|
}
|
|
if needNew {
|
|
var err error
|
|
for attempt := 0; attempt < 8; attempt++ {
|
|
tok, err = randomHexToken(16)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, clash := seen[tok]; !clash {
|
|
break
|
|
}
|
|
}
|
|
if err := db.Unscoped().Model(&models.ProxyUser{}).Where("id = ?", row.ID).Update("sub_token", tok).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
seen[tok] = row.ID
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func randomHexToken(n int) (string, error) {
|
|
b := make([]byte, n)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(b), nil
|
|
}
|