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
This commit is contained in:
MHSanaei 2026-05-24 22:52:33 +02:00
parent ea926826fb
commit 97967535b6
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
2 changed files with 76 additions and 6 deletions

View file

@ -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

View file

@ -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)
}
}