From 2a8c3bc0db9157c109f42d9a5130b402f7b29418 Mon Sep 17 00:00:00 2001 From: Sanaei Date: Sun, 2 Aug 2026 12:32:45 +0200 Subject: [PATCH] fix(clients): stop a stale IP row from blocking a client edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saving a client walks every inbound it is attached to and calls UpdateInboundClient, which re-keys the client's email in inbound_client_ips to the spelling in the edited settings. The email match is EqualFold, so when an inbound's settings JSON drifted in case from the client record the panel issues a case-only rename of the tracking row. inbound_client_ips.client_email is unique and case-sensitive, and the IP-limit job keys its rows on whatever casing Xray reports, so both spellings can already be present. The rename then aborts the whole edit with "duplicate key value violates unique constraint uni_inbound_client_ips_client_email" — the client could not be saved at all, including when only adding an inbound to it. The caller only renames onto an identity no live client holds, so a row on the target email is stale IP tracking: delete it before renaming. The blob is rebuilt by the next scan anyway. --- .../web/service/client_update_rename_test.go | 37 +++++++++++++++++++ internal/web/service/inbound_client_ips.go | 7 ++++ 2 files changed, 44 insertions(+) diff --git a/internal/web/service/client_update_rename_test.go b/internal/web/service/client_update_rename_test.go index 163cfd137..7ead6fc83 100644 --- a/internal/web/service/client_update_rename_test.go +++ b/internal/web/service/client_update_rename_test.go @@ -76,6 +76,43 @@ func TestUpdateInboundClientCaseOnlyRenameDoesNotDuplicateRecord(t *testing.T) { } } +// The IP-limit job keys its tracking rows on the casing Xray reports, so an +// inbound whose settings JSON drifted in case leaves a row under each spelling. +func TestUpdateInboundClientCaseOnlyRenameSurvivesExistingClientIpsRow(t *testing.T) { + setupBulkDB(t) + svc := &ClientService{} + inboundSvc := &InboundService{} + + source := []model.Client{{Email: "Sanaei", ID: "aaaaaaaa-0000-0000-0000-000000000009", SubID: "sub-ips", Enable: true}} + ib := mkInbound(t, 22011, model.VLESS, clientsSettings(t, source)) + if err := svc.SyncInbound(nil, ib.Id, source); err != nil { + t.Fatalf("seed linkage: %v", err) + } + for _, email := range []string{"Sanaei", "sanaei"} { + row := &model.InboundClientIps{ClientEmail: email, Ips: `[{"ip":"1.2.3.4","timestamp":1700000000}]`} + if err := database.GetDB().Create(row).Error; err != nil { + t.Fatalf("seed client ips for %q: %v", email, err) + } + } + + lowered := source + lowered[0].Email = "sanaei" + if _, err := svc.UpdateInboundClient(inboundSvc, &model.Inbound{ + Id: ib.Id, + Settings: clientsSettings(t, lowered), + }, "sanaei"); err != nil { + t.Fatalf("UpdateInboundClient with a colliding client ips row: %v", err) + } + + var rows []model.InboundClientIps + if err := database.GetDB().Find(&rows).Error; err != nil { + t.Fatalf("read client ips: %v", err) + } + if len(rows) != 1 || rows[0].ClientEmail != "sanaei" { + t.Fatalf("client ips rows after rename = %+v, want a single row for %q", rows, "sanaei") + } +} + func TestClientUpdateDuplicateSubIDDoesNotRenameEmail(t *testing.T) { setupBulkDB(t) svc := &ClientService{} diff --git a/internal/web/service/inbound_client_ips.go b/internal/web/service/inbound_client_ips.go index 1b54a69e3..dabd049ac 100644 --- a/internal/web/service/inbound_client_ips.go +++ b/internal/web/service/inbound_client_ips.go @@ -152,6 +152,13 @@ func (s *InboundService) MergeInboundClientIps(incomingIps []model.InboundClient } func (s *InboundService) UpdateClientIPs(tx *gorm.DB, oldEmail string, newEmail string) error { + // The caller only renames onto a free identity, so a row already sitting on + // newEmail is stale tracking data — drop it instead of failing the edit. + if oldEmail != newEmail { + if err := tx.Where("client_email = ?", newEmail).Delete(model.InboundClientIps{}).Error; err != nil { + return err + } + } return tx.Model(model.InboundClientIps{}).Where("client_email = ?", oldEmail).Update("client_email", newEmail).Error }