From 97967535b6e418d53d813ac022a268a9086bced8 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 24 May 2026 22:52:33 +0200 Subject: [PATCH] fix(clients): preserve protocol-specific credentials across multi-inbound syncs (#4538) fillProtocolDefaults only populates the credential relevant to the inbound's protocol (c.ID for VLESS, c.Auth for Hysteria, c.Password for Trojan/Shadowsocks). Each inbound's settings.clients JSON therefore carries the same client with only one of those fields set. SyncInbound's update path was unconditionally copying every credential column from incoming to the existing clients row, so the second sync (e.g. Hysteria after VLESS) would write UUID="" over a valid VLESS UUID and Auth="" the other way around. The next GetXrayConfig then emitted VLESS client entries with no "id" field, and xray-core crashed on startup with "common/uuid: invalid UUID:". Guard UUID/Password/Auth/Flow/Security/Reverse against empty overwrites so each protocol's sync only writes the credentials it actually owns. Other fields (LimitIP, TotalGB, Comment, etc.) keep the existing copy-everything behavior so admins can still clear them through the panel. Regression test in client_sync_multiprotocol_test.go. Closes #4538 --- web/service/client.go | 24 ++++++-- web/service/client_sync_multiprotocol_test.go | 58 +++++++++++++++++++ 2 files changed, 76 insertions(+), 6 deletions(-) create mode 100644 web/service/client_sync_multiprotocol_test.go diff --git a/web/service/client.go b/web/service/client.go index a464029e..b25854d6 100644 --- a/web/service/client.go +++ b/web/service/client.go @@ -213,12 +213,24 @@ func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model. } row = incoming } else { - row.UUID = incoming.UUID - row.Password = incoming.Password - row.Auth = incoming.Auth - row.Flow = incoming.Flow - row.Security = incoming.Security - row.Reverse = incoming.Reverse + if incoming.UUID != "" { + row.UUID = incoming.UUID + } + if incoming.Password != "" { + row.Password = incoming.Password + } + if incoming.Auth != "" { + row.Auth = incoming.Auth + } + if incoming.Flow != "" { + row.Flow = incoming.Flow + } + if incoming.Security != "" { + row.Security = incoming.Security + } + if incoming.Reverse != "" { + row.Reverse = incoming.Reverse + } row.SubID = incoming.SubID row.LimitIP = incoming.LimitIP row.TotalGB = incoming.TotalGB diff --git a/web/service/client_sync_multiprotocol_test.go b/web/service/client_sync_multiprotocol_test.go new file mode 100644 index 00000000..335c7e82 --- /dev/null +++ b/web/service/client_sync_multiprotocol_test.go @@ -0,0 +1,58 @@ +package service + +import ( + "path/filepath" + "testing" + + "github.com/mhsanaei/3x-ui/v3/database" + "github.com/mhsanaei/3x-ui/v3/database/model" +) + +func TestSyncInbound_PreservesCredentialsAcrossProtocols(t *testing.T) { + dbDir := t.TempDir() + t.Setenv("XUI_DB_FOLDER", dbDir) + if err := database.InitDB(filepath.Join(dbDir, "3x-ui.db")); err != nil { + t.Fatalf("InitDB: %v", err) + } + t.Cleanup(func() { _ = database.CloseDB() }) + + db := database.GetDB() + + vlessInbound := &model.Inbound{Tag: "vless-in", Enable: true, Port: 10001, Protocol: model.VLESS} + if err := db.Create(vlessInbound).Error; err != nil { + t.Fatalf("create vless inbound: %v", err) + } + hysteriaInbound := &model.Inbound{Tag: "hy-in", Enable: true, Port: 10002, Protocol: model.Hysteria2} + if err := db.Create(hysteriaInbound).Error; err != nil { + t.Fatalf("create hysteria inbound: %v", err) + } + + svc := ClientService{} + const sharedEmail = "shared@example.com" + const wantUUID = "ce8d33df-3a64-4f10-8f9b-91c3a8e0c001" + const wantAuth = "h2-auth-token" + + vlessClient := model.Client{Email: sharedEmail, ID: wantUUID, Enable: true, Flow: "xtls-rprx-vision"} + if err := svc.SyncInbound(nil, vlessInbound.Id, []model.Client{vlessClient}); err != nil { + t.Fatalf("vless SyncInbound: %v", err) + } + + hysteriaClient := model.Client{Email: sharedEmail, Auth: wantAuth, Enable: true} + if err := svc.SyncInbound(nil, hysteriaInbound.Id, []model.Client{hysteriaClient}); err != nil { + t.Fatalf("hysteria SyncInbound: %v", err) + } + + var row model.ClientRecord + if err := db.Where("email = ?", sharedEmail).First(&row).Error; err != nil { + t.Fatalf("lookup client row: %v", err) + } + if row.UUID != wantUUID { + t.Errorf("UUID was clobbered by Hysteria sync: got %q, want %q", row.UUID, wantUUID) + } + if row.Auth != wantAuth { + t.Errorf("Auth not persisted: got %q, want %q", row.Auth, wantAuth) + } + if row.Flow != "xtls-rprx-vision" { + t.Errorf("Flow was clobbered by Hysteria sync: got %q, want xtls-rprx-vision", row.Flow) + } +}