diff --git a/web/service/client.go b/web/service/client.go index 3307de42..ffaacda7 100644 --- a/web/service/client.go +++ b/web/service/client.go @@ -299,9 +299,7 @@ func (s *ClientService) ListForInbound(tx *gorm.DB, inboundId int) ([]model.Clie out := make([]model.Client, 0, len(rows)) for i := range rows { c := rows[i].ToClient() - if rows[i].FlowOverride != "" { - c.Flow = rows[i].FlowOverride - } + c.Flow = rows[i].FlowOverride out = append(out, *c) } return out, nil @@ -455,7 +453,7 @@ func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreate if err := s.fillProtocolDefaults(&client, inbound); err != nil { return needRestart, err } - settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {client}}) + settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {clientWithInboundFlow(client, inbound)}}) if mErr != nil { return needRestart, mErr } @@ -496,8 +494,13 @@ func (s *ClientService) fillProtocolDefaults(c *model.Client, ib *model.Inbound) return nil } -// shadowsocksMethodFromSettings pulls the "method" field out of the inbound's -// settings JSON. Returns "" when the field is missing or settings is invalid. +func clientWithInboundFlow(c model.Client, ib *model.Inbound) model.Client { + if !inboundCanEnableTlsFlow(string(ib.Protocol), ib.StreamSettings) { + c.Flow = "" + } + return c +} + func shadowsocksMethodFromSettings(settings string) string { if settings == "" { return "" @@ -510,11 +513,6 @@ func shadowsocksMethodFromSettings(settings string) string { return method } -// randomShadowsocksClientKey returns a per-client key sized to the cipher. -// The 2022-blake3 ciphers require a base64-encoded key of an exact byte -// length (16 bytes for aes-128-gcm, 32 bytes for aes-256-gcm and -// chacha20-poly1305) — anything else fails with "bad key" on xray start. -// Older ciphers accept arbitrary passwords, so we keep the uuid-style. func randomShadowsocksClientKey(method string) string { if n := shadowsocksKeyBytes(method); n > 0 { return random.Base64Bytes(n) @@ -522,9 +520,6 @@ func randomShadowsocksClientKey(method string) string { return strings.ReplaceAll(uuid.NewString(), "-", "") } -// validShadowsocksClientKey reports whether key is acceptable for the cipher. -// For 2022-blake3 it must decode to the exact byte length the cipher needs; -// any other method accepts any non-empty string. func validShadowsocksClientKey(method, key string) bool { n := shadowsocksKeyBytes(method) if n == 0 { @@ -547,13 +542,6 @@ func shadowsocksKeyBytes(method string) int { return 0 } -// applyShadowsocksClientMethod normalises the per-client "method" field -// when an inbound is created or updated: -// - Legacy ciphers: backfill `method` so xray's multi-user code is happy. -// "unsupported cipher method:" otherwise. -// - 2022-blake3-*: strip the per-client `method` because xray rejects -// it with "users must have empty method". This matters after an admin -// switches an existing inbound from a legacy cipher to a 2022 one. func applyShadowsocksClientMethod(clients []any, settings map[string]any) { method, _ := settings["method"].(string) is2022 := strings.HasPrefix(method, "2022-blake3-") @@ -604,10 +592,6 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model updated.CreatedAt = existing.CreatedAt } - // Rename the ClientRecord row up front when the email changes. SyncInbound - // (invoked from UpdateInboundClient below) looks up by email — without - // renaming first it would treat the new email as a brand-new client, - // insert a duplicate ClientRecord, and leave the original orphaned. if updated.Email != existing.Email { var collisionCount int64 if err := database.GetDB().Model(&model.ClientRecord{}). @@ -646,7 +630,7 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model if err := s.fillProtocolDefaults(&updated, inbound); err != nil { return needRestart, err } - settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {updated}}) + settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {clientWithInboundFlow(updated, inbound)}}) if mErr != nil { return needRestart, mErr } @@ -752,7 +736,7 @@ func (s *ClientService) Attach(inboundSvc *InboundService, id int, inboundIds [] if err := s.fillProtocolDefaults(©Client, inbound); err != nil { return needRestart, err } - settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {copyClient}}) + settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {clientWithInboundFlow(copyClient, inbound)}}) if mErr != nil { return needRestart, mErr } @@ -870,7 +854,7 @@ func (s *ClientService) BulkAttach(inboundSvc *InboundService, emails []string, recordErr("%s -> inbound %d: %v", rec.Email, ibId, err) continue } - clientsToAdd = append(clientsToAdd, client) + clientsToAdd = append(clientsToAdd, clientWithInboundFlow(client, inbound)) } if len(clientsToAdd) == 0 { diff --git a/web/service/client_flow_isolation_test.go b/web/service/client_flow_isolation_test.go new file mode 100644 index 00000000..bd0dd230 --- /dev/null +++ b/web/service/client_flow_isolation_test.go @@ -0,0 +1,85 @@ +package service + +import ( + "path/filepath" + "testing" + + "github.com/mhsanaei/3x-ui/v3/database" + "github.com/mhsanaei/3x-ui/v3/database/model" +) + +func TestClientWithInboundFlow_GatesByInboundCapability(t *testing.T) { + const vision = "xtls-rprx-vision" + cases := []struct { + name string + protocol model.Protocol + streamSettings string + wantFlow string + }{ + {"vless tcp reality keeps flow", model.VLESS, `{"network":"tcp","security":"reality"}`, vision}, + {"vless tcp tls keeps flow", model.VLESS, `{"network":"tcp","security":"tls"}`, vision}, + {"vless ws tls clears flow", model.VLESS, `{"network":"ws","security":"tls"}`, ""}, + {"vless grpc tls clears flow", model.VLESS, `{"network":"grpc","security":"tls"}`, ""}, + {"vless tcp none clears flow", model.VLESS, `{"network":"tcp","security":"none"}`, ""}, + {"vmess tcp tls clears flow", model.VMESS, `{"network":"tcp","security":"tls"}`, ""}, + {"empty stream clears flow", model.VLESS, "", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ib := &model.Inbound{Protocol: tc.protocol, StreamSettings: tc.streamSettings} + got := clientWithInboundFlow(model.Client{Email: "x@example.com", Flow: vision}, ib) + if got.Flow != tc.wantFlow { + t.Errorf("Flow = %q, want %q", got.Flow, tc.wantFlow) + } + }) + } +} + +func TestFlowIsolation_VisionDoesNotLeakToWsInbound(t *testing.T) { + dbDir := t.TempDir() + t.Setenv("XUI_DB_FOLDER", dbDir) + if err := database.InitDB(filepath.Join(dbDir, "3x-ui.db")); err != nil { + t.Fatalf("InitDB: %v", err) + } + t.Cleanup(func() { _ = database.CloseDB() }) + + db := database.GetDB() + + wsTls := &model.Inbound{Tag: "vless-ws", Enable: true, Port: 30001, Protocol: model.VLESS, StreamSettings: `{"network":"ws","security":"tls"}`} + if err := db.Create(wsTls).Error; err != nil { + t.Fatalf("create ws+tls inbound: %v", err) + } + reality := &model.Inbound{Tag: "vless-reality", Enable: true, Port: 30002, Protocol: model.VLESS, StreamSettings: `{"network":"tcp","security":"reality"}`} + if err := db.Create(reality).Error; err != nil { + t.Fatalf("create reality inbound: %v", err) + } + + svc := ClientService{} + const email = "shared@example.com" + const uid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0c003" + const vision = "xtls-rprx-vision" + + source := model.Client{Email: email, ID: uid, Enable: true, Flow: vision} + for _, ib := range []*model.Inbound{wsTls, reality} { + gated := clientWithInboundFlow(source, ib) + if err := svc.SyncInbound(nil, ib.Id, []model.Client{gated}); err != nil { + t.Fatalf("SyncInbound(%s): %v", ib.Tag, err) + } + } + + realityList, err := svc.ListForInbound(nil, reality.Id) + if err != nil { + t.Fatalf("ListForInbound(reality): %v", err) + } + if len(realityList) != 1 || realityList[0].Flow != vision { + t.Errorf("Reality inbound should keep flow=%q, got %#v", vision, realityList) + } + + wsList, err := svc.ListForInbound(nil, wsTls.Id) + if err != nil { + t.Fatalf("ListForInbound(ws): %v", err) + } + if len(wsList) != 1 || wsList[0].Flow != "" { + t.Errorf("WS+TLS inbound must not inherit Vision flow (#4628), got %#v", wsList) + } +} diff --git a/web/service/inbound.go b/web/service/inbound.go index 155fe1a2..360ae116 100644 --- a/web/service/inbound.go +++ b/web/service/inbound.go @@ -1074,7 +1074,7 @@ func (s *InboundService) generateRandomCredential(targetProtocol model.Protocol) } } -func (s *InboundService) buildTargetClientFromSource(source model.Client, targetProtocol model.Protocol, email string, flow string) (model.Client, error) { +func (s *InboundService) buildTargetClientFromSource(source model.Client, targetInbound *model.Inbound, email string, flow string) (model.Client, error) { nowTs := time.Now().UnixMilli() target := source target.Email = email @@ -1086,12 +1086,14 @@ func (s *InboundService) buildTargetClientFromSource(source model.Client, target target.Auth = "" target.Flow = "" + targetProtocol := targetInbound.Protocol switch targetProtocol { case model.VMESS: target.ID = s.generateRandomCredential(targetProtocol) case model.VLESS: target.ID = s.generateRandomCredential(targetProtocol) - if flow == "xtls-rprx-vision" || flow == "xtls-rprx-vision-udp443" { + if (flow == "xtls-rprx-vision" || flow == "xtls-rprx-vision-udp443") && + inboundCanEnableTlsFlow(string(targetProtocol), targetInbound.StreamSettings) { target.Flow = flow } case model.Trojan, model.Shadowsocks: @@ -1192,7 +1194,7 @@ func (s *InboundService) CopyInboundClients(targetInboundID int, sourceInboundID } targetEmail := s.nextAvailableCopiedEmail(originalEmail, targetInboundID, occupiedEmails) - targetClient, buildErr := s.buildTargetClientFromSource(sourceClient, targetInbound.Protocol, targetEmail, flow) + targetClient, buildErr := s.buildTargetClientFromSource(sourceClient, targetInbound, targetEmail, flow) if buildErr != nil { result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", originalEmail, buildErr)) continue