mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-12 07:36:07 +00:00
fix(clients): rename client record atomically with inbound settings
Update committed an email rename to the clients table with a standalone write before the per-inbound loop rewrote each inbound's settings JSON. In that window the record held the new email while the JSON still held the old one, so any concurrent SyncInbound (traffic poll, another edit) found no record for the old email and inserted a duplicate seeded from the stale JSON - carrying the same subId, which then failed every later edit with "Duplicate subId". The subId collision check also ran after that write, so even a rejected update permanently renamed the email. Move the rename inside UpdateInboundClient's serialized transaction, next to the settings save and SyncInbound, so no other writer can see one without the other; skip it when a record already owns the target email (the merge case). Update now only runs collision checks before the loop and falls back to a direct rename solely for records with no attached inbound. This covers both the REST API and the web UI editor, which share this path. Closes #5870
This commit is contained in:
@@ -373,11 +373,6 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
|
||||
if collisionCount > 0 {
|
||||
return false, common.NewError("Duplicate email:", updated.Email)
|
||||
}
|
||||
if err := database.GetDB().Model(&model.ClientRecord{}).
|
||||
Where("id = ?", id).
|
||||
Update("email", updated.Email).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
if updated.SubID != "" {
|
||||
@@ -428,6 +423,16 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateInboundClient renames the record atomically with each inbound's
|
||||
// settings JSON; this direct write only covers records with no inbound left.
|
||||
if updated.Email != existing.Email {
|
||||
if err := database.GetDB().Model(&model.ClientRecord{}).
|
||||
Where("id = ? AND email = ?", id, existing.Email).
|
||||
Update("email", updated.Email).Error; err != nil {
|
||||
return needRestart, err
|
||||
}
|
||||
}
|
||||
|
||||
reverseStr := ""
|
||||
if updated.Reverse != nil && strings.TrimSpace(updated.Reverse.Tag) != "" {
|
||||
if b, mErr := json.Marshal(updated.Reverse); mErr == nil {
|
||||
|
||||
@@ -805,6 +805,19 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
|
||||
if e := tx.Save(oldInbound).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
// Rename the client record in the same transaction as the settings JSON
|
||||
// so no concurrent SyncInbound can see one renamed without the other.
|
||||
if len(oldEmail) > 0 && !strings.EqualFold(oldEmail, clients[0].Email) {
|
||||
var renameTaken int64
|
||||
if e := tx.Model(&model.ClientRecord{}).Where("email = ?", clients[0].Email).Count(&renameTaken).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
if renameTaken == 0 {
|
||||
if e := tx.Model(&model.ClientRecord{}).Where("email = ?", oldEmail).Update("email", clients[0].Email).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
}
|
||||
finalClients, gcErr := inboundSvc.GetClients(oldInbound)
|
||||
if gcErr != nil {
|
||||
return gcErr
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func countClientRecords(t *testing.T) int64 {
|
||||
t.Helper()
|
||||
var n int64
|
||||
if err := database.GetDB().Model(&model.ClientRecord{}).Count(&n).Error; err != nil {
|
||||
t.Fatalf("count client records: %v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func TestUpdateInboundClientRenameDoesNotDuplicateRecord(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
source := []model.Client{{Email: "old@x", ID: "aaaaaaaa-0000-0000-0000-000000000001", SubID: "sub-old", Enable: true}}
|
||||
ib := mkInbound(t, 22001, model.VLESS, clientsSettings(t, source))
|
||||
if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
|
||||
t.Fatalf("seed linkage: %v", err)
|
||||
}
|
||||
origId := lookupClientRecord(t, "old@x").Id
|
||||
|
||||
renamed := source
|
||||
renamed[0].Email = "new@x"
|
||||
if _, err := svc.UpdateInboundClient(inboundSvc, &model.Inbound{
|
||||
Id: ib.Id,
|
||||
Settings: clientsSettings(t, renamed),
|
||||
}, "old@x"); err != nil {
|
||||
t.Fatalf("UpdateInboundClient: %v", err)
|
||||
}
|
||||
|
||||
if n := countClientRecords(t); n != 1 {
|
||||
t.Fatalf("client records after rename = %d, want 1", n)
|
||||
}
|
||||
rec := lookupClientRecord(t, "new@x")
|
||||
if rec.Id != origId {
|
||||
t.Fatalf("record id after rename = %d, want %d", rec.Id, origId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientUpdateDuplicateSubIDDoesNotRenameEmail(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
source := []model.Client{
|
||||
{Email: "keep@x", ID: "aaaaaaaa-0000-0000-0000-000000000003", SubID: "sub-keep", Enable: true},
|
||||
{Email: "other@x", ID: "aaaaaaaa-0000-0000-0000-000000000004", SubID: "sub-other", Enable: true},
|
||||
}
|
||||
ib := mkInbound(t, 22003, model.VLESS, clientsSettings(t, source))
|
||||
if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
|
||||
t.Fatalf("seed linkage: %v", err)
|
||||
}
|
||||
origId := lookupClientRecord(t, "keep@x").Id
|
||||
origSettings := mustInboundSettings(t, inboundSvc, ib.Id)
|
||||
|
||||
updated := source[0]
|
||||
updated.Email = "kept@x"
|
||||
updated.SubID = "sub-other"
|
||||
if _, err := svc.Update(inboundSvc, origId, updated); err == nil {
|
||||
t.Fatalf("Update with colliding subId succeeded, want error")
|
||||
}
|
||||
|
||||
rec := lookupClientRecord(t, "keep@x")
|
||||
if rec.Id != origId {
|
||||
t.Fatalf("record id changed after rejected update")
|
||||
}
|
||||
if got := mustInboundSettings(t, inboundSvc, ib.Id); got != origSettings {
|
||||
t.Fatalf("inbound settings changed after rejected update")
|
||||
}
|
||||
}
|
||||
|
||||
func mustInboundSettings(t *testing.T, inboundSvc *InboundService, id int) string {
|
||||
t.Helper()
|
||||
ib, err := inboundSvc.GetInbound(id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetInbound %d: %v", id, err)
|
||||
}
|
||||
return ib.Settings
|
||||
}
|
||||
Reference in New Issue
Block a user