Parcourir la source

fix(flow): restore XTLS Vision when an inbound becomes flow-eligible (#5520)

* fix(flow): restore XTLS Vision when an inbound becomes flow-eligible

clientWithInboundFlow strips Vision from a VLESS client whenever the target
inbound is not flow-eligible at client-write time — e.g. an XHTTP inbound
before its vlessenc (ML-KEM) encryption is set, or a client attached to such
an inbound. Nothing restored the flow once the inbound later became eligible:
an inbound edit stores its settings verbatim and never re-gates the clients.
So enabling encryption on an existing XHTTP inbound left every client without
flow, and the generated configs, share links and subscriptions silently
dropped flow=xtls-rprx-vision — most visibly on node inbounds and on any
inbound where encryption was turned on after the clients existed.

Restore the flow at the two points where an inbound can become eligible:

- UpdateInbound: after the new stream/settings are final, re-add Vision to
  clients that currently carry no flow but whose intended flow (their
  flow_override on a sibling inbound, via EffectiveFlowByEmail) is Vision —
  only when the inbound is now flow-eligible.
- MigrationRestoreVisionFlow: a one-time, idempotent boot migration that
  applies the same repair to existing installs and refreshes flow_override
  via SyncInbound.

The repair is conservative: it never invents a flow for a client that has
none anywhere, never overwrites an explicit flow, and is a no-op on healthy
installs. Adds EffectiveFlowByEmail and a unit test covering keep/skip/no-op
cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* style(flow): serialize restored settings with MarshalIndent

Match the indented JSON used by the adjacent timestamp block in UpdateInbound
and the externalProxy migration, so a restored inbound's settings column keeps
the same multi-line format as everything else (review nit on #5520).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* perf(flow): batch the intended-flow lookup and run it on the active tx

restoreVisionFlowForEligibleInbound resolved each empty-flow client's intended
flow with EffectiveFlowByEmail, which issued two queries per client
(GetRecordByEmail + EffectiveFlow). A client that genuinely uses no Vision keeps
an empty flow forever, so it was re-queried on every UpdateInbound and every
boot — O(clients) queries per save on a Reality/TCP or XHTTP+vlessenc inbound
carrying many non-Vision clients, executed inside the serialized writer
transaction.

Replace it with EffectiveFlowsByEmails: collect every empty-flow email first and
resolve them in a single batched join over client_inbounds + clients (lowest
inbound_id wins, same rule as before), chunked for the SQLite bind-var limit.

Also thread the active tx through restoreVisionFlowForEligibleInbound so the
read runs on the writer's own connection while it holds the lock instead of a
separate pooled connection (UpdateInbound passes its tx; the boot migration
passes nil → GetDB() as before).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
Rouzbeh† il y a 15 heures
Parent
commit
82600936d6

+ 64 - 0
internal/web/service/client_effective_flow_test.go

@@ -0,0 +1,64 @@
+package service
+
+import (
+	"path/filepath"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// EffectiveFlowsByEmails resolves intended flow for many clients in one batched
+// query, taking the flow_override of the lowest inbound_id and skipping emails
+// with no non-empty flow anywhere.
+func TestEffectiveFlowsByEmails(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()
+
+	const vision = "xtls-rprx-vision"
+
+	// vis@x: attached to inbound 20 (empty flow) and 10 (Vision) — lowest
+	// inbound_id (10) wins, so the empty override on 20 must not mask it.
+	// plain@x: only an empty flow_override anywhere — absent from the result.
+	mkClient := func(id int, email string) {
+		if err := db.Create(&model.ClientRecord{Id: id, Email: email, Enable: true}).Error; err != nil {
+			t.Fatalf("create client %s: %v", email, err)
+		}
+	}
+	mkLink := func(clientID, inboundID int, flow string) {
+		if err := db.Create(&model.ClientInbound{ClientId: clientID, InboundId: inboundID, FlowOverride: flow}).Error; err != nil {
+			t.Fatalf("link %d/%d: %v", clientID, inboundID, err)
+		}
+	}
+	mkClient(1, "vis@x")
+	mkClient(2, "plain@x")
+	mkLink(1, 20, "")     // higher inbound_id, empty
+	mkLink(1, 10, vision) // lower inbound_id, Vision
+	mkLink(2, 30, "")     // only empty override
+
+	cs := &ClientService{}
+	got, err := cs.EffectiveFlowsByEmails(nil, []string{"vis@x", "plain@x", "missing@x"})
+	if err != nil {
+		t.Fatalf("EffectiveFlowsByEmails: %v", err)
+	}
+
+	if got["vis@x"] != vision {
+		t.Errorf("vis@x = %q, want %q (lowest inbound_id flow_override)", got["vis@x"], vision)
+	}
+	if v, ok := got["plain@x"]; ok {
+		t.Errorf("plain@x present (%q); want absent (no non-empty flow anywhere)", v)
+	}
+	if v, ok := got["missing@x"]; ok {
+		t.Errorf("missing@x present (%q); want absent (unknown client)", v)
+	}
+
+	// Empty input is a no-op (no query).
+	if m, err := cs.EffectiveFlowsByEmails(nil, nil); err != nil || len(m) != 0 {
+		t.Errorf("empty input: got %v err %v, want empty map", m, err)
+	}
+}

+ 38 - 0
internal/web/service/client_lookup.go

@@ -49,6 +49,44 @@ func (s *ClientService) EffectiveFlow(tx *gorm.DB, recordId int) (string, error)
 	return flows[0], nil
 }
 
+// EffectiveFlowsByEmails resolves the intended flow (non-empty flow_override,
+// lowest inbound_id first — same rule as EffectiveFlow) for many clients in one
+// query, keyed by email. Emails absent from the result carry no flow anywhere.
+// Batched so flow restoration on an inbound with many clients is O(1) queries
+// instead of O(clients). Used to restore a stripped flow onto an inbound that
+// has just become flow-eligible.
+func (s *ClientService) EffectiveFlowsByEmails(tx *gorm.DB, emails []string) (map[string]string, error) {
+	if tx == nil {
+		tx = database.GetDB()
+	}
+	out := make(map[string]string, len(emails))
+	if len(emails) == 0 {
+		return out, nil
+	}
+	type row struct {
+		Email string
+		Flow  string `gorm:"column:flow_override"`
+	}
+	for _, batch := range chunkStrings(emails, sqlInChunk) {
+		var rows []row
+		err := tx.Table("client_inbounds").
+			Select("clients.email AS email, client_inbounds.flow_override AS flow_override").
+			Joins("JOIN clients ON clients.id = client_inbounds.client_id").
+			Where("clients.email IN ? AND client_inbounds.flow_override <> ?", batch, "").
+			Order("client_inbounds.inbound_id ASC").
+			Scan(&rows).Error
+		if err != nil {
+			return nil, err
+		}
+		for _, r := range rows {
+			if _, seen := out[r.Email]; !seen { // ordered by inbound_id ASC → first = lowest
+				out[r.Email] = r.Flow
+			}
+		}
+	}
+	return out, nil
+}
+
 func (s *ClientService) GetInboundIdsForEmail(tx *gorm.DB, email string) ([]int, error) {
 	if tx == nil {
 		tx = database.GetDB()

+ 8 - 0
internal/web/service/inbound.go

@@ -1065,6 +1065,14 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 			logger.Warning("Shadowsocks inbound", inbound.Id, "method change resized keys; regenerated mismatched client PSK(s)")
 		}
 
+		// Re-gate Vision flow now that the new stream/encryption is known: if this
+		// VLESS inbound just became flow-eligible (e.g. vlessenc was enabled on an
+		// XHTTP inbound), restore Vision for clients whose intended flow is Vision
+		// but was stripped while the inbound was ineligible.
+		if restored, changed := s.restoreVisionFlowForEligibleInbound(tx, inbound.Settings, inbound.StreamSettings, inbound.Protocol); changed {
+			inbound.Settings = restored
+		}
+
 		oldInbound.Total = inbound.Total
 		oldInbound.Remark = inbound.Remark
 		oldInbound.SubSortIndex = inbound.SubSortIndex

+ 90 - 0
internal/web/service/inbound_flow_restore.go

@@ -0,0 +1,90 @@
+package service
+
+import (
+	"encoding/json"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+
+	"gorm.io/gorm"
+)
+
+const visionFlow = "xtls-rprx-vision"
+
+// restoreVisionFlowForEligibleInbound re-adds the XTLS Vision flow to a VLESS
+// inbound's clients that lost it earlier.
+//
+// clientWithInboundFlow strips Vision from a client whenever the target inbound
+// is not flow-eligible at write time (e.g. an XHTTP inbound before its vlessenc
+// encryption is set). Nothing restored the flow when the inbound later became
+// eligible — an inbound edit stores its settings verbatim and never re-gates the
+// clients — so enabling encryption on an existing XHTTP inbound left every
+// client without flow, and the share links/subscriptions dropped it.
+//
+// This runs on the now-final inbound settings: when the inbound IS flow-eligible
+// it sets flow=Vision on each client that currently has no flow but whose
+// intended flow (its flow_override on a sibling inbound, via EffectiveFlowsByEmails)
+// is Vision. It never invents a flow for a client that has none anywhere, and it
+// never overwrites an explicit non-empty flow. Returns the rewritten settings
+// JSON and whether anything changed.
+func (s *InboundService) restoreVisionFlowForEligibleInbound(tx *gorm.DB, settings, streamSettings string, protocol model.Protocol) (string, bool) {
+	if protocol != model.VLESS {
+		return settings, false
+	}
+	if !inboundCanEnableTlsFlow(string(protocol), streamSettings, settings) {
+		return settings, false
+	}
+	var parsed map[string]any
+	if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
+		return settings, false
+	}
+	clients, ok := parsed["clients"].([]any)
+	if !ok || len(clients) == 0 {
+		return settings, false
+	}
+	// Collect empty-flow clients, then resolve their intended flow in one query.
+	emails := make([]string, 0, len(clients))
+	for i := range clients {
+		cm, ok := clients[i].(map[string]any)
+		if !ok {
+			continue
+		}
+		if flow, _ := cm["flow"].(string); flow != "" {
+			continue // respect an explicit flow (Vision or otherwise)
+		}
+		if email, _ := cm["email"].(string); email != "" {
+			emails = append(emails, email)
+		}
+	}
+	if len(emails) == 0 {
+		return settings, false
+	}
+	intended, err := s.clientService.EffectiveFlowsByEmails(tx, emails)
+	if err != nil {
+		return settings, false
+	}
+	changed := false
+	for i := range clients {
+		cm, ok := clients[i].(map[string]any)
+		if !ok {
+			continue
+		}
+		if flow, _ := cm["flow"].(string); flow != "" {
+			continue
+		}
+		email, _ := cm["email"].(string)
+		if intended[email] != visionFlow {
+			continue
+		}
+		cm["flow"] = visionFlow
+		clients[i] = cm
+		changed = true
+	}
+	if !changed {
+		return settings, false
+	}
+	out, err := json.MarshalIndent(parsed, "", "  ")
+	if err != nil {
+		return settings, false
+	}
+	return string(out), true
+}

+ 96 - 0
internal/web/service/inbound_flow_restore_test.go

@@ -0,0 +1,96 @@
+package service
+
+import (
+	"encoding/json"
+	"path/filepath"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// restoreVisionFlowForEligibleInbound must re-add Vision to a client whose flow
+// was stripped while the XHTTP inbound was not yet vlessenc-encrypted, but only
+// when the client's intended flow (its flow_override on a sibling) is Vision,
+// only on now-eligible inbounds, and never overwriting an explicit flow.
+func TestRestoreVisionFlowForEligibleInbound(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()
+
+	const vision = "xtls-rprx-vision"
+	const realityStream = `{"network":"tcp","security":"reality"}`
+	const xhttpEnc = `{"network":"xhttp","security":"reality"}`
+	const encSettings = `"decryption":"mlkem768x25519plus.native.0rtt.KEY","encryption":"mlkem768x25519plus.native.0rtt.KEY"`
+
+	cs := &ClientService{}
+	ibSvc := &InboundService{}
+
+	// Sibling reality inbound where the client legitimately has Vision.
+	sibling := &model.Inbound{
+		Tag: "sib", Enable: true, Port: 51001, Protocol: model.VLESS, StreamSettings: realityStream,
+		Settings: `{"clients":[{"id":"u1","email":"keep@x","flow":"` + vision + `","subId":"s1","enable":true}]}`,
+	}
+	if err := db.Create(sibling).Error; err != nil {
+		t.Fatalf("create sibling: %v", err)
+	}
+	keep, _ := ibSvc.GetClients(sibling)
+	if err := cs.SyncInbound(nil, sibling.Id, keep); err != nil {
+		t.Fatalf("sync sibling: %v", err)
+	}
+
+	// A client with no intended Vision anywhere — must NOT be touched.
+	other := &model.Inbound{
+		Tag: "oth", Enable: true, Port: 51002, Protocol: model.VLESS, StreamSettings: realityStream,
+		Settings: `{"clients":[{"id":"u2","email":"none@x","subId":"s2","enable":true}]}`,
+	}
+	if err := db.Create(other).Error; err != nil {
+		t.Fatalf("create other: %v", err)
+	}
+	oc, _ := ibSvc.GetClients(other)
+	if err := cs.SyncInbound(nil, other.Id, oc); err != nil {
+		t.Fatalf("sync other: %v", err)
+	}
+
+	// The now-eligible XHTTP inbound: keep@x has empty flow (was stripped),
+	// none@x has empty flow (no Vision anywhere), set@x has an explicit empty
+	// stays empty unless intended Vision.
+	target := `{` + encSettings + `,"clients":[` +
+		`{"id":"u1","email":"keep@x","flow":"","subId":"s1","enable":true},` +
+		`{"id":"u2","email":"none@x","flow":"","subId":"s2","enable":true}` +
+		`]}`
+
+	out, changed := ibSvc.restoreVisionFlowForEligibleInbound(nil, target, xhttpEnc, model.VLESS)
+	if !changed {
+		t.Fatal("expected changed=true")
+	}
+	var parsed map[string]any
+	if err := json.Unmarshal([]byte(out), &parsed); err != nil {
+		t.Fatalf("parse out: %v", err)
+	}
+	flows := map[string]string{}
+	for _, c := range parsed["clients"].([]any) {
+		cm := c.(map[string]any)
+		flows[cm["email"].(string)], _ = cm["flow"].(string)
+	}
+	if flows["keep@x"] != vision {
+		t.Errorf("keep@x flow = %q, want Vision (intended on sibling)", flows["keep@x"])
+	}
+	if flows["none@x"] != "" {
+		t.Errorf("none@x flow = %q, want empty (no Vision intent)", flows["none@x"])
+	}
+
+	// Ineligible inbound (xhttp without encryption) must be a no-op.
+	noenc := `{"clients":[{"id":"u1","email":"keep@x","flow":"","subId":"s1","enable":true}]}`
+	if _, ch := ibSvc.restoreVisionFlowForEligibleInbound(nil, noenc, `{"network":"xhttp","security":"reality"}`, model.VLESS); ch {
+		t.Error("ineligible xhttp (no vlessenc) must not change")
+	}
+	// Non-VLESS must be a no-op.
+	if _, ch := ibSvc.restoreVisionFlowForEligibleInbound(nil, target, xhttpEnc, model.VMESS); ch {
+		t.Error("non-VLESS must not change")
+	}
+}

+ 41 - 0
internal/web/service/inbound_migration.go

@@ -253,4 +253,45 @@ func (s *InboundService) MigrationRequirements() {
 func (s *InboundService) MigrateDB() {
 	s.MigrationRequirements()
 	s.MigrationRemoveOrphanedTraffics()
+	s.MigrationRestoreVisionFlow()
+}
+
+// MigrationRestoreVisionFlow repairs VLESS inbounds whose clients lost their
+// XTLS Vision flow because the inbound was not flow-eligible when the client was
+// written (e.g. an XHTTP inbound whose vlessenc encryption was enabled only
+// later). For each now-eligible inbound it restores flow=xtls-rprx-vision on
+// clients whose intended flow (their flow_override on a sibling inbound) is
+// Vision. Idempotent: once a client carries the flow it is skipped, so this is a
+// no-op on healthy installs and on subsequent boots.
+func (s *InboundService) MigrationRestoreVisionFlow() {
+	db := database.GetDB()
+	var inbounds []*model.Inbound
+	if err := db.Model(&model.Inbound{}).
+		Where("protocol = ?", model.VLESS).
+		Find(&inbounds).Error; err != nil {
+		logger.Warning("MigrationRestoreVisionFlow: load inbounds failed:", err)
+		return
+	}
+	for _, ib := range inbounds {
+		restored, changed := s.restoreVisionFlowForEligibleInbound(nil, ib.Settings, ib.StreamSettings, ib.Protocol)
+		if !changed {
+			continue
+		}
+		clients, err := s.GetClients(&model.Inbound{Settings: restored})
+		if err != nil {
+			logger.Warning("MigrationRestoreVisionFlow: parse clients for inbound", ib.Id, "failed:", err)
+			continue
+		}
+		err = db.Transaction(func(tx *gorm.DB) error {
+			if e := tx.Model(&model.Inbound{}).Where("id = ?", ib.Id).Update("settings", restored).Error; e != nil {
+				return e
+			}
+			return s.clientService.SyncInbound(tx, ib.Id, clients)
+		})
+		if err != nil {
+			logger.Warning("MigrationRestoreVisionFlow: update inbound", ib.Id, "failed:", err)
+			continue
+		}
+		logger.Info("MigrationRestoreVisionFlow: restored XTLS Vision flow on inbound", ib.Id)
+	}
 }