From 1e3c186b2c5138abd7e464fb672a350b0468b395 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Tue, 2 Jun 2026 15:32:48 +0200 Subject: [PATCH] fix(clients): derive edit-form flow from per-inbound override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SyncInbound runs once per inbound and unconditionally overwrites the canonical clients.Flow column. A non-flow inbound (Hysteria, WS, gRPC) strips flow to "", so when it syncs after a VLESS Reality inbound the column is wiped, and the hydrate endpoint returned that empty value — the edit form loaded a blank flow for multi-inbound clients (#4792). Derive the hydrate flow from the first flow-capable client_inbounds.flow_override instead, which is always correct and order-independent. A non-empty guard in SyncInbound was rejected because it would make flow impossible to clear. Closes #4792 --- web/controller/client.go | 6 ++ web/service/client.go | 26 ++++++ web/service/client_flow_isolation_test.go | 96 +++++++++++++++++++++++ 3 files changed, 128 insertions(+) diff --git a/web/controller/client.go b/web/controller/client.go index 439afca9..e57d26d9 100644 --- a/web/controller/client.go +++ b/web/controller/client.go @@ -93,6 +93,12 @@ func (a *ClientController) get(c *gin.Context) { jsonMsg(c, I18nWeb(c, "get"), err) return } + flow, err := a.clientService.EffectiveFlow(nil, rec.Id) + if err != nil { + jsonMsg(c, I18nWeb(c, "get"), err) + return + } + rec.Flow = flow jsonObj(c, gin.H{"client": rec, "inboundIds": inboundIds}, nil) } diff --git a/web/service/client.go b/web/service/client.go index 9d35a552..a3040e89 100644 --- a/web/service/client.go +++ b/web/service/client.go @@ -316,6 +316,32 @@ func (s *ClientService) GetRecordByEmail(tx *gorm.DB, email string) (*model.Clie return row, nil } +// EffectiveFlow returns the client's flow from the first flow-capable inbound +// it is attached to (lowest inbound_id with a non-empty flow_override). The +// canonical clients.Flow column is unreliable for multi-inbound clients: a +// non-flow inbound (Hysteria, WS, gRPC, …) carries an empty flow and, when its +// SyncInbound runs last, overwrites the column to "" even though a VLESS Reality +// inbound stored a real flow. The per-inbound flow_override is always correct, +// so derive the display flow from it (order-independent). See issue #4792. +func (s *ClientService) EffectiveFlow(tx *gorm.DB, recordId int) (string, error) { + if tx == nil { + tx = database.GetDB() + } + var flows []string + err := tx.Model(&model.ClientInbound{}). + Where("client_id = ? AND flow_override <> ?", recordId, ""). + Order("inbound_id ASC"). + Limit(1). + Pluck("flow_override", &flows).Error + if err != nil { + return "", err + } + if len(flows) == 0 { + return "", nil + } + return flows[0], nil +} + func (s *ClientService) GetInboundIdsForEmail(tx *gorm.DB, email string) ([]int, error) { if tx == nil { tx = database.GetDB() diff --git a/web/service/client_flow_isolation_test.go b/web/service/client_flow_isolation_test.go index 546ceff1..95820f3f 100644 --- a/web/service/client_flow_isolation_test.go +++ b/web/service/client_flow_isolation_test.go @@ -83,3 +83,99 @@ func TestFlowIsolation_VisionDoesNotLeakToWsInbound(t *testing.T) { t.Errorf("WS+TLS inbound must not inherit Vision flow (#4628), got %#v", wsList) } } + +func TestEffectiveFlow_NonFlowInboundSyncedLastDoesNotHideVision(t *testing.T) { + dbDir := t.TempDir() + t.Setenv("XUI_DB_FOLDER", dbDir) + if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil { + t.Fatalf("InitDB: %v", err) + } + t.Cleanup(func() { _ = database.CloseDB() }) + + db := database.GetDB() + reality := &model.Inbound{Tag: "vless-reality", Enable: true, Port: 40001, Protocol: model.VLESS, StreamSettings: `{"network":"tcp","security":"reality"}`} + if err := db.Create(reality).Error; err != nil { + t.Fatalf("create reality inbound: %v", err) + } + hysteria := &model.Inbound{Tag: "hysteria", Enable: true, Port: 40002, Protocol: model.Hysteria, StreamSettings: `{"security":"tls"}`} + if err := db.Create(hysteria).Error; err != nil { + t.Fatalf("create hysteria inbound: %v", err) + } + + svc := ClientService{} + const email = "shared@example.com" + const uid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0c099" + const vision = "xtls-rprx-vision" + + source := model.Client{Email: email, ID: uid, Auth: uid, Enable: true, Flow: vision} + // Reproduce #4792 ordering: the flow-capable inbound (Reality) syncs first, + // the non-flow inbound (Hysteria) syncs last and wipes clients.Flow to "". + for _, ib := range []*model.Inbound{reality, hysteria} { + gated := clientWithInboundFlow(source, ib) + if err := svc.SyncInbound(nil, ib.Id, []model.Client{gated}); err != nil { + t.Fatalf("SyncInbound(%s): %v", ib.Tag, err) + } + } + + rec, err := svc.GetRecordByEmail(nil, email) + if err != nil { + t.Fatalf("GetRecordByEmail: %v", err) + } + if rec.Flow != "" { + t.Logf("note: canonical clients.Flow = %q (denormalized, not authoritative)", rec.Flow) + } + + got, err := svc.EffectiveFlow(nil, rec.Id) + if err != nil { + t.Fatalf("EffectiveFlow: %v", err) + } + if got != vision { + t.Errorf("EffectiveFlow = %q, want %q — the edit form would show a blank flow (#4792)", got, vision) + } +} + +func TestEffectiveFlow_ClearedFlowStaysCleared(t *testing.T) { + dbDir := t.TempDir() + t.Setenv("XUI_DB_FOLDER", dbDir) + if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil { + t.Fatalf("InitDB: %v", err) + } + t.Cleanup(func() { _ = database.CloseDB() }) + + db := database.GetDB() + reality := &model.Inbound{Tag: "vless-reality", Enable: true, Port: 41001, Protocol: model.VLESS, StreamSettings: `{"network":"tcp","security":"reality"}`} + if err := db.Create(reality).Error; err != nil { + t.Fatalf("create reality inbound: %v", err) + } + hysteria := &model.Inbound{Tag: "hysteria", Enable: true, Port: 41002, Protocol: model.Hysteria, StreamSettings: `{"security":"tls"}`} + if err := db.Create(hysteria).Error; err != nil { + t.Fatalf("create hysteria inbound: %v", err) + } + + svc := ClientService{} + const email = "noflow@example.com" + const uid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0c0aa" + + // User chose no flow: every inbound carries "". A non-empty guard in + // SyncInbound would make this impossible to express; EffectiveFlow must + // still report "". + source := model.Client{Email: email, ID: uid, Auth: uid, Enable: true, Flow: ""} + for _, ib := range []*model.Inbound{reality, hysteria} { + gated := clientWithInboundFlow(source, ib) + if err := svc.SyncInbound(nil, ib.Id, []model.Client{gated}); err != nil { + t.Fatalf("SyncInbound(%s): %v", ib.Tag, err) + } + } + + rec, err := svc.GetRecordByEmail(nil, email) + if err != nil { + t.Fatalf("GetRecordByEmail: %v", err) + } + got, err := svc.EffectiveFlow(nil, rec.Id) + if err != nil { + t.Fatalf("EffectiveFlow: %v", err) + } + if got != "" { + t.Errorf("EffectiveFlow = %q, want empty (cleared flow must stay cleared)", got) + } +}