mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 12:44:22 +00:00
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
This commit is contained in:
parent
c9abda7ab8
commit
1e3c186b2c
3 changed files with 128 additions and 0 deletions
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue