mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-31 10:14:15 +00:00
fix(clients): store flow per-inbound for shared clients
A client shared across inbounds (e.g. VLESS+TCP+Reality and VLESS+WS+TLS) had its `flow` applied globally, so enabling xtls-rprx-vision for Reality broke the WS+TLS inbound for the same client (#4628). Gate flow per inbound at every fan-out site via clientWithInboundFlow, reusing inboundCanEnableTlsFlow (VLESS+TCP+TLS/Reality only), and make ListForInbound treat flow_override as authoritative so an empty override means "no flow on this inbound" instead of inheriting the record's global flow. Also tighten buildTargetClientFromSource (copy-clients) to gate on transport, not just protocol.
This commit is contained in:
parent
8e301dbca9
commit
7ea88e3e37
3 changed files with 102 additions and 31 deletions
|
|
@ -299,9 +299,7 @@ func (s *ClientService) ListForInbound(tx *gorm.DB, inboundId int) ([]model.Clie
|
||||||
out := make([]model.Client, 0, len(rows))
|
out := make([]model.Client, 0, len(rows))
|
||||||
for i := range rows {
|
for i := range rows {
|
||||||
c := rows[i].ToClient()
|
c := rows[i].ToClient()
|
||||||
if rows[i].FlowOverride != "" {
|
|
||||||
c.Flow = rows[i].FlowOverride
|
c.Flow = rows[i].FlowOverride
|
||||||
}
|
|
||||||
out = append(out, *c)
|
out = append(out, *c)
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
|
|
@ -455,7 +453,7 @@ func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreate
|
||||||
if err := s.fillProtocolDefaults(&client, inbound); err != nil {
|
if err := s.fillProtocolDefaults(&client, inbound); err != nil {
|
||||||
return needRestart, err
|
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 {
|
if mErr != nil {
|
||||||
return needRestart, mErr
|
return needRestart, mErr
|
||||||
}
|
}
|
||||||
|
|
@ -496,8 +494,13 @@ func (s *ClientService) fillProtocolDefaults(c *model.Client, ib *model.Inbound)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// shadowsocksMethodFromSettings pulls the "method" field out of the inbound's
|
func clientWithInboundFlow(c model.Client, ib *model.Inbound) model.Client {
|
||||||
// settings JSON. Returns "" when the field is missing or settings is invalid.
|
if !inboundCanEnableTlsFlow(string(ib.Protocol), ib.StreamSettings) {
|
||||||
|
c.Flow = ""
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
func shadowsocksMethodFromSettings(settings string) string {
|
func shadowsocksMethodFromSettings(settings string) string {
|
||||||
if settings == "" {
|
if settings == "" {
|
||||||
return ""
|
return ""
|
||||||
|
|
@ -510,11 +513,6 @@ func shadowsocksMethodFromSettings(settings string) string {
|
||||||
return method
|
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 {
|
func randomShadowsocksClientKey(method string) string {
|
||||||
if n := shadowsocksKeyBytes(method); n > 0 {
|
if n := shadowsocksKeyBytes(method); n > 0 {
|
||||||
return random.Base64Bytes(n)
|
return random.Base64Bytes(n)
|
||||||
|
|
@ -522,9 +520,6 @@ func randomShadowsocksClientKey(method string) string {
|
||||||
return strings.ReplaceAll(uuid.NewString(), "-", "")
|
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 {
|
func validShadowsocksClientKey(method, key string) bool {
|
||||||
n := shadowsocksKeyBytes(method)
|
n := shadowsocksKeyBytes(method)
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
|
|
@ -547,13 +542,6 @@ func shadowsocksKeyBytes(method string) int {
|
||||||
return 0
|
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) {
|
func applyShadowsocksClientMethod(clients []any, settings map[string]any) {
|
||||||
method, _ := settings["method"].(string)
|
method, _ := settings["method"].(string)
|
||||||
is2022 := strings.HasPrefix(method, "2022-blake3-")
|
is2022 := strings.HasPrefix(method, "2022-blake3-")
|
||||||
|
|
@ -604,10 +592,6 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
|
||||||
updated.CreatedAt = existing.CreatedAt
|
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 {
|
if updated.Email != existing.Email {
|
||||||
var collisionCount int64
|
var collisionCount int64
|
||||||
if err := database.GetDB().Model(&model.ClientRecord{}).
|
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 {
|
if err := s.fillProtocolDefaults(&updated, inbound); err != nil {
|
||||||
return needRestart, err
|
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 {
|
if mErr != nil {
|
||||||
return needRestart, mErr
|
return needRestart, mErr
|
||||||
}
|
}
|
||||||
|
|
@ -752,7 +736,7 @@ func (s *ClientService) Attach(inboundSvc *InboundService, id int, inboundIds []
|
||||||
if err := s.fillProtocolDefaults(©Client, inbound); err != nil {
|
if err := s.fillProtocolDefaults(©Client, inbound); err != nil {
|
||||||
return needRestart, err
|
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 {
|
if mErr != nil {
|
||||||
return needRestart, mErr
|
return needRestart, mErr
|
||||||
}
|
}
|
||||||
|
|
@ -870,7 +854,7 @@ func (s *ClientService) BulkAttach(inboundSvc *InboundService, emails []string,
|
||||||
recordErr("%s -> inbound %d: %v", rec.Email, ibId, err)
|
recordErr("%s -> inbound %d: %v", rec.Email, ibId, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
clientsToAdd = append(clientsToAdd, client)
|
clientsToAdd = append(clientsToAdd, clientWithInboundFlow(client, inbound))
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(clientsToAdd) == 0 {
|
if len(clientsToAdd) == 0 {
|
||||||
|
|
|
||||||
85
web/service/client_flow_isolation_test.go
Normal file
85
web/service/client_flow_isolation_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
nowTs := time.Now().UnixMilli()
|
||||||
target := source
|
target := source
|
||||||
target.Email = email
|
target.Email = email
|
||||||
|
|
@ -1086,12 +1086,14 @@ func (s *InboundService) buildTargetClientFromSource(source model.Client, target
|
||||||
target.Auth = ""
|
target.Auth = ""
|
||||||
target.Flow = ""
|
target.Flow = ""
|
||||||
|
|
||||||
|
targetProtocol := targetInbound.Protocol
|
||||||
switch targetProtocol {
|
switch targetProtocol {
|
||||||
case model.VMESS:
|
case model.VMESS:
|
||||||
target.ID = s.generateRandomCredential(targetProtocol)
|
target.ID = s.generateRandomCredential(targetProtocol)
|
||||||
case model.VLESS:
|
case model.VLESS:
|
||||||
target.ID = s.generateRandomCredential(targetProtocol)
|
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
|
target.Flow = flow
|
||||||
}
|
}
|
||||||
case model.Trojan, model.Shadowsocks:
|
case model.Trojan, model.Shadowsocks:
|
||||||
|
|
@ -1192,7 +1194,7 @@ func (s *InboundService) CopyInboundClients(targetInboundID int, sourceInboundID
|
||||||
}
|
}
|
||||||
|
|
||||||
targetEmail := s.nextAvailableCopiedEmail(originalEmail, targetInboundID, occupiedEmails)
|
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 {
|
if buildErr != nil {
|
||||||
result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", originalEmail, buildErr))
|
result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", originalEmail, buildErr))
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue