diff --git a/internal/web/service/client_effective_flow_test.go b/internal/web/service/client_effective_flow_test.go new file mode 100644 index 000000000..8a09f5c6b --- /dev/null +++ b/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) + } +} diff --git a/internal/web/service/client_lookup.go b/internal/web/service/client_lookup.go index cd423989a..2e7a5c111 100644 --- a/internal/web/service/client_lookup.go +++ b/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() diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index fd00cea63..286ae4ebd 100644 --- a/internal/web/service/inbound.go +++ b/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 diff --git a/internal/web/service/inbound_flow_restore.go b/internal/web/service/inbound_flow_restore.go new file mode 100644 index 000000000..e1accf89b --- /dev/null +++ b/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 +} diff --git a/internal/web/service/inbound_flow_restore_test.go b/internal/web/service/inbound_flow_restore_test.go new file mode 100644 index 000000000..21f178634 --- /dev/null +++ b/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") + } +} diff --git a/internal/web/service/inbound_migration.go b/internal/web/service/inbound_migration.go index 23bd67f7d..fdc1f659f 100644 --- a/internal/web/service/inbound_migration.go +++ b/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) + } }