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