Pārlūkot izejas kodu

fix(clients): derive edit-form flow from per-inbound override

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
MHSanaei 16 stundas atpakaļ
vecāks
revīzija
1e3c186b2c

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

+ 26 - 0
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()

+ 96 - 0
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 = "[email protected]"
+	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 = "[email protected]"
+	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)
+	}
+}