From 6dbd219a147a461a5921cac8b0ff767bf5cdeee8 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 01:16:56 +0100 Subject: [PATCH] fix(database): keep disabled clients disabled in the ClientsTable seeder The one-time ClientsTable migration builds client records from each inbound's settings with a plain Create, and gorm's default:true on ClientRecord.Enable drops an explicit false from that insert. Every client an operator had disabled before v3.1.0 was therefore stored enabled on an in-place upgrade or a restored backup, and Xray, which now reads that column, let the customer back in. The seeder restates enable=false after Create, as the service paths do since #6478, and a legacy client whose settings carry no switch stays enabled, as releases before 3.1.0 served it. --- internal/database/db.go | 13 ++++++++++ internal/database/db_seed_test.go | 41 +++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/internal/database/db.go b/internal/database/db.go index 0e92b626a..80b680365 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -3197,6 +3197,11 @@ func seedClientsFromInboundJSON() error { continue } model.NormalizeLegacyClientFields(obj) + // Releases before 3.1.0 served a client whose JSON carries no + // switch; only an explicit false means disabled. + if obj["enable"] == nil { + obj["enable"] = true + } blob, err := json.Marshal(obj) if err != nil { continue @@ -3218,6 +3223,14 @@ func seedClientsFromInboundJSON() error { if err := tx.Create(incoming).Error; err != nil { return err } + // gorm default:true drops enable=false on Create — restate (#6478). + if !c.Enable { + if err := tx.Model(&model.ClientRecord{}).Where("id = ?", incoming.Id). + UpdateColumn("enable", false).Error; err != nil { + return err + } + incoming.Enable = false + } byEmail[email] = incoming row = incoming } else { diff --git a/internal/database/db_seed_test.go b/internal/database/db_seed_test.go index 8ce6d2c16..8bf177254 100644 --- a/internal/database/db_seed_test.go +++ b/internal/database/db_seed_test.go @@ -220,6 +220,47 @@ func TestSeedClientsFromInboundJSON_IsIdempotentAgainstExistingClients(t *testin } } +func TestSeedClientsFromInboundJSON_KeepsTheManualSwitch(t *testing.T) { + dbDir := t.TempDir() + t.Setenv("XUI_DB_FOLDER", dbDir) + if err := InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil { + t.Fatalf("InitDB failed: %v", err) + } + t.Cleanup(func() { _ = CloseDB() }) + + inbound := model.Inbound{ + UserId: 1, + Port: 12345, + Protocol: model.VLESS, + Settings: `{"clients": [ + {"id": "ce8d33df-3a64-4f10-8f9b-91c3a8e0c001", "email": "paying", "enable": true}, + {"id": "ce8d33df-3a64-4f10-8f9b-91c3a8e0c002", "email": "banned", "enable": false}, + {"id": "ce8d33df-3a64-4f10-8f9b-91c3a8e0c003", "email": "no-switch"} + ]}`, + Tag: "test-inbound", + } + if err := db.Create(&inbound).Error; err != nil { + t.Fatalf("seed inbound: %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: %v", err) + } + + for email, want := range map[string]bool{"paying": true, "banned": false, "no-switch": true} { + var row model.ClientRecord + if err := db.Where("email = ?", email).First(&row).Error; err != nil { + t.Fatalf("read client %s: %v", email, err) + } + if row.Enable != want { + t.Errorf("client %s migrated with enable = %v, want %v", email, row.Enable, want) + } + } +} + func TestNormalizeInboundClientSubId_FillsMissingAndPreservesExisting(t *testing.T) { dbDir := t.TempDir() t.Setenv("XUI_DB_FOLDER", dbDir)