Files
kazeyukiro a690ecd3f4 fix(auth,ui): reduce SQLite busy 503; TCP/UDP dual connection chart
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.
2026-10-02 04:52:22 +00:00

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
}