fix: auto-fill vision flow for eligible new clients

This commit is contained in:
root 2026-04-26 00:53:11 +08:00
parent 3048950743
commit e3d84b38ca
3 changed files with 255 additions and 0 deletions

View file

@ -0,0 +1,64 @@
Task Record
Date: 2026-04-26
Related Module: web/service inbound client management
Change Type: Fix
Background
When adding new clients under VLESS + TCP + (TLS/Reality), `flow` may be left empty.
This causes missing expected default flow behavior and inconsistent client configuration.
Requirement is to auto-fill `flow` with `xtls-rprx-vision` only when the client context requires flow.
Changes
Added backend auto-fill logic for new clients:
- Introduced `shouldAutoFillVisionFlow(...)` to detect flow-required context:
- protocol is `vless`
- stream `network` is `tcp`
- stream `security` is `tls` or `reality`
- Introduced `autoFillVisionFlowInSettings(...)` to fill empty/missing client `flow` as `xtls-rprx-vision`.
Integrated into add/update flows:
- `AddInbound`: auto-fill for initial clients of a newly created inbound.
- `AddInboundClient`: auto-fill for clients added via add-client endpoint (includes bulk add and TG bot path).
- `UpdateInbound`: auto-fill only for newly added VLESS clients (does not override existing clients).
Added tests:
- `web/service/inbound_flow_autofill_test.go`
- all eligible clients are auto-filled
- selected new clients only can be targeted
- no change when flow is not required
Impact
Affected modules or files.
- `web/service/inbound.go`
- `web/service/inbound_flow_autofill_test.go`
Whether APIs, database, config, build, or compatibility are affected.
- API endpoints unchanged.
- Database schema unchanged.
- Runtime behavior change: new clients in eligible VLESS context auto-receive `xtls-rprx-vision` flow.
Whether upstream or downstream callers are affected.
- UI add-client, bulk add-client, and TG bot add-client now share consistent default flow behavior via backend logic.
Verification
List validation commands or checks performed.
- `go test ./web/service/...`
State the result.
- Passed.
If not verified, explain why.
- No remote runtime deployment test was performed in local environment.
Risks And Follow-Up
Remaining risks.
- If operators expect empty flow for newly added VLESS+TCP+TLS/Reality clients, behavior is now intentionally changed.
Recommended follow-up work.
- If needed, expose a setting switch to opt out of default flow auto-fill.

View file

@ -232,6 +232,72 @@ func (s *InboundService) checkEmailExistInInbound(inbound *model.Inbound, email
return false, nil
}
func shouldAutoFillVisionFlow(protocol model.Protocol, streamSettings string) bool {
if protocol != model.VLESS {
return false
}
var stream map[string]any
if err := json.Unmarshal([]byte(streamSettings), &stream); err != nil {
return false
}
network, _ := stream["network"].(string)
security, _ := stream["security"].(string)
return network == "tcp" && (security == "tls" || security == "reality")
}
func autoFillVisionFlowInSettings(settingsJSON string, protocol model.Protocol, streamSettings string, clientIDs map[string]struct{}) (string, bool, error) {
if !shouldAutoFillVisionFlow(protocol, streamSettings) {
return settingsJSON, false, nil
}
var settings map[string]any
if err := json.Unmarshal([]byte(settingsJSON), &settings); err != nil {
return settingsJSON, false, err
}
interfaceClients, ok := settings["clients"].([]any)
if !ok {
return settingsJSON, false, nil
}
changed := false
applyToAll := len(clientIDs) == 0
for i := range interfaceClients {
clientMap, ok := interfaceClients[i].(map[string]any)
if !ok {
continue
}
if !applyToAll {
clientID, _ := clientMap["id"].(string)
if _, shouldApply := clientIDs[clientID]; !shouldApply {
continue
}
}
flow, hasFlow := clientMap["flow"]
flowStr, _ := flow.(string)
if !hasFlow || strings.TrimSpace(flowStr) == "" {
clientMap["flow"] = "xtls-rprx-vision"
interfaceClients[i] = clientMap
changed = true
}
}
if !changed {
return settingsJSON, false, nil
}
settings["clients"] = interfaceClients
bs, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return settingsJSON, false, err
}
return string(bs), true, nil
}
// AddInbound creates a new inbound configuration.
// It validates port uniqueness, client email uniqueness, and required fields,
// then saves the inbound to the database and optionally adds it to the running Xray instance.
@ -249,6 +315,12 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
return inbound, false, common.NewError("Port already exists:", inbound.Port)
}
if updatedSettings, changed, err := autoFillVisionFlowInSettings(inbound.Settings, inbound.Protocol, inbound.StreamSettings, nil); err != nil {
return inbound, false, err
} else if changed {
inbound.Settings = updatedSettings
}
existEmail, err := s.checkEmailExistForInbound(inbound)
if err != nil {
return inbound, false, err
@ -526,6 +598,39 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
return inbound, false, err
}
if inbound.Protocol == model.VLESS {
oldClients, err := s.GetClients(oldInbound)
if err != nil {
return inbound, false, err
}
newClients, err := s.GetClients(inbound)
if err != nil {
return inbound, false, err
}
oldIDs := make(map[string]struct{}, len(oldClients))
for _, c := range oldClients {
if c.ID != "" {
oldIDs[c.ID] = struct{}{}
}
}
newOnlyIDs := map[string]struct{}{}
for _, c := range newClients {
if c.ID == "" {
continue
}
if _, exists := oldIDs[c.ID]; !exists {
newOnlyIDs[c.ID] = struct{}{}
}
}
if len(newOnlyIDs) > 0 {
updatedSettings, _, err := autoFillVisionFlowInSettings(inbound.Settings, inbound.Protocol, inbound.StreamSettings, newOnlyIDs)
if err != nil {
return inbound, false, err
}
inbound.Settings = updatedSettings
}
}
tag := oldInbound.Tag
db := database.GetDB()
@ -735,6 +840,20 @@ func (s *InboundService) AddInboundClient(data *model.Inbound) (bool, error) {
return false, err
}
if updatedSettings, changed, err := autoFillVisionFlowInSettings(data.Settings, oldInbound.Protocol, oldInbound.StreamSettings, nil); err != nil {
return false, err
} else if changed {
data.Settings = updatedSettings
clients, err = s.GetClients(data)
if err != nil {
return false, err
}
if err = json.Unmarshal([]byte(data.Settings), &settings); err != nil {
return false, err
}
interfaceClients, _ = settings["clients"].([]any)
}
// Check email uniqueness within this inbound only
for _, client := range clients {
if client.Email == "" {

View file

@ -0,0 +1,72 @@
package service
import (
"strings"
"testing"
"github.com/goccy/go-json"
"github.com/mhsanaei/3x-ui/v2/database/model"
)
func TestAutoFillVisionFlowInSettings_AllClients(t *testing.T) {
settings := `{"clients":[{"id":"a","email":"a@test","flow":""},{"id":"b","email":"b@test"}]}`
stream := `{"network":"tcp","security":"tls"}`
updated, changed, err := autoFillVisionFlowInSettings(settings, model.VLESS, stream, nil)
if err != nil {
t.Fatalf("autoFillVisionFlowInSettings() error = %v", err)
}
if !changed {
t.Fatalf("expected changed=true")
}
if strings.Count(updated, "xtls-rprx-vision") != 2 {
t.Fatalf("expected both clients to be auto-filled, got: %s", updated)
}
}
func TestAutoFillVisionFlowInSettings_SelectedClientsOnly(t *testing.T) {
settings := `{"clients":[{"id":"a","email":"a@test","flow":""},{"id":"b","email":"b@test","flow":""}]}`
stream := `{"network":"tcp","security":"reality"}`
targetIDs := map[string]struct{}{"b": {}}
updated, changed, err := autoFillVisionFlowInSettings(settings, model.VLESS, stream, targetIDs)
if err != nil {
t.Fatalf("autoFillVisionFlowInSettings() error = %v", err)
}
if !changed {
t.Fatalf("expected changed=true")
}
var m map[string]any
if err := json.Unmarshal([]byte(updated), &m); err != nil {
t.Fatalf("unmarshal updated settings: %v", err)
}
clients, ok := m["clients"].([]any)
if !ok || len(clients) != 2 {
t.Fatalf("clients parse failed: %#v", m["clients"])
}
a, _ := clients[0].(map[string]any)
b, _ := clients[1].(map[string]any)
if flowA, _ := a["flow"].(string); flowA != "" {
t.Fatalf("client a flow should remain empty, got %q", flowA)
}
if flowB, _ := b["flow"].(string); flowB != "xtls-rprx-vision" {
t.Fatalf("client b flow should be auto-filled, got %q", flowB)
}
}
func TestAutoFillVisionFlowInSettings_SkipWhenFlowNotRequired(t *testing.T) {
settings := `{"clients":[{"id":"a","email":"a@test","flow":""}]}`
stream := `{"network":"ws","security":"tls"}`
updated, changed, err := autoFillVisionFlowInSettings(settings, model.VLESS, stream, nil)
if err != nil {
t.Fatalf("autoFillVisionFlowInSettings() error = %v", err)
}
if changed {
t.Fatalf("expected changed=false")
}
if updated != settings {
t.Fatalf("settings should stay unchanged")
}
}