mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 20:54:14 +00:00
fix: hash-storage panic on SIGHUP and seeder dup-key on cold restart (#4539)
Two bugs that combine into an unrecoverable crash loop after a user enables the Telegram bot in settings on a fresh install. 1. CheckHashStorageJob.Run panics with a nil pointer dereference. The cron job is scheduled whenever settings say the bot is enabled, but the package-level hash storage is only initialized inside Tgbot.Start, which StartPanelOnly intentionally skips (startTgBot=false). Toggling the bot on via the panel triggers SIGHUP, the storage stays nil, and the cron fires 2 minutes later and panics, exiting 2. 2. seedClientsFromInboundJSON is not idempotent. The fresh-install early-return path recorded only UserPasswordHash + ApiTokensTable, never ClientsTable. After the admin adds clients via the panel (which writes to the clients table through SyncInbound), the next start runs the seeder for the first time, finds matching emails already in the table, and fails with SQLSTATE 23505 on idx_clients_email, turning the panic above into an unrecoverable crash loop on PostgreSQL. Fixes: - web/job/check_hash_storage.go: nil-check the storage before calling RemoveExpiredHashes. - database/db.go: in the fresh-install early-return path, also record ClientsTable so the seeder never re-runs against panel-added data. - database/db.go: hydrate seedClientsFromInboundJSON's byEmail cache from existing rows so it merges instead of inserting when a row with the same email already lives in the clients table. Regression tests cover both paths. Closes #4539
This commit is contained in:
parent
b196f481a8
commit
ea926826fb
4 changed files with 101 additions and 7 deletions
|
|
@ -142,11 +142,11 @@ func runSeeders(isUsersEmpty bool) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
if empty && isUsersEmpty {
|
if empty && isUsersEmpty {
|
||||||
hashSeeder := &model.HistoryOfSeeders{
|
seeders := []string{"UserPasswordHash", "ClientsTable"}
|
||||||
SeederName: "UserPasswordHash",
|
for _, name := range seeders {
|
||||||
}
|
if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
|
||||||
if err := db.Create(hashSeeder).Error; err != nil {
|
return err
|
||||||
return err
|
}
|
||||||
}
|
}
|
||||||
return seedApiTokens()
|
return seedApiTokens()
|
||||||
}
|
}
|
||||||
|
|
@ -237,6 +237,14 @@ func seedClientsFromInboundJSON() error {
|
||||||
return db.Transaction(func(tx *gorm.DB) error {
|
return db.Transaction(func(tx *gorm.DB) error {
|
||||||
byEmail := map[string]*model.ClientRecord{}
|
byEmail := map[string]*model.ClientRecord{}
|
||||||
|
|
||||||
|
var existing []model.ClientRecord
|
||||||
|
if err := tx.Find(&existing).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i := range existing {
|
||||||
|
byEmail[existing[i].Email] = &existing[i]
|
||||||
|
}
|
||||||
|
|
||||||
for _, inbound := range inbounds {
|
for _, inbound := range inbounds {
|
||||||
if strings.TrimSpace(inbound.Settings) == "" {
|
if strings.TrimSpace(inbound.Settings) == "" {
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
71
database/db_seed_test.go
Normal file
71
database/db_seed_test.go
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSeedClientsFromInboundJSON_IsIdempotentAgainstExistingClients(t *testing.T) {
|
||||||
|
dbDir := t.TempDir()
|
||||||
|
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||||
|
if err := InitDB(filepath.Join(dbDir, "3x-ui.db")); err != nil {
|
||||||
|
t.Fatalf("InitDB failed: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = CloseDB() })
|
||||||
|
|
||||||
|
settings, err := json.Marshal(map[string]any{
|
||||||
|
"clients": []any{
|
||||||
|
map[string]any{
|
||||||
|
"id": "ce8d33df-3a64-4f10-8f9b-91c3a8e0c001",
|
||||||
|
"email": "alice@example.com",
|
||||||
|
"enable": true,
|
||||||
|
"flow": "",
|
||||||
|
"subId": "alice-sub",
|
||||||
|
"comment": "from-inbound-json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal settings: %v", err)
|
||||||
|
}
|
||||||
|
inbound := model.Inbound{
|
||||||
|
UserId: 1,
|
||||||
|
Port: 12345,
|
||||||
|
Protocol: model.VLESS,
|
||||||
|
Settings: string(settings),
|
||||||
|
Tag: "test-inbound",
|
||||||
|
}
|
||||||
|
if err := db.Create(&inbound).Error; err != nil {
|
||||||
|
t.Fatalf("seed inbound: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
preExisting := &model.ClientRecord{
|
||||||
|
Email: "alice@example.com",
|
||||||
|
UUID: "ce8d33df-3a64-4f10-8f9b-91c3a8e0c001",
|
||||||
|
SubID: "alice-sub",
|
||||||
|
Enable: true,
|
||||||
|
Comment: "added-via-api",
|
||||||
|
}
|
||||||
|
if err := db.Create(preExisting).Error; err != nil {
|
||||||
|
t.Fatalf("seed client row: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Where("seeder_name = ?", "ClientsTable").Delete(&model.HistoryOfSeeders{}).Error; err != nil {
|
||||||
|
t.Fatalf("clear ClientsTable history: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := seedClientsFromInboundJSON(); err != nil {
|
||||||
|
t.Fatalf("seedClientsFromInboundJSON should be idempotent against existing rows, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
if err := db.Model(&model.ClientRecord{}).Where("email = ?", "alice@example.com").Count(&count).Error; err != nil {
|
||||||
|
t.Fatalf("count clients: %v", err)
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Fatalf("alice@example.com should resolve to exactly one row, got %d", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,9 @@ func NewCheckHashStorageJob() *CheckHashStorageJob {
|
||||||
|
|
||||||
// Run removes expired hash entries from the Telegram bot's hash storage.
|
// Run removes expired hash entries from the Telegram bot's hash storage.
|
||||||
func (j *CheckHashStorageJob) Run() {
|
func (j *CheckHashStorageJob) Run() {
|
||||||
// Remove expired hashes from storage
|
storage := j.tgbotService.GetHashStorage()
|
||||||
j.tgbotService.GetHashStorage().RemoveExpiredHashes()
|
if storage == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
storage.RemoveExpiredHashes()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
12
web/job/check_hash_storage_test.go
Normal file
12
web/job/check_hash_storage_test.go
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
package job
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestCheckHashStorageJob_RunWithoutPanicWhenStorageNil(t *testing.T) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
t.Fatalf("CheckHashStorageJob.Run panicked when storage is nil: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
NewCheckHashStorageJob().Run()
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue