diff --git a/docs/Tasktracking/2026-04-26-auto-fill-vision-flow-for-new-clients.md b/docs/Tasktracking/2026-04-26-auto-fill-vision-flow-for-new-clients.md new file mode 100644 index 00000000..8fdca6d9 --- /dev/null +++ b/docs/Tasktracking/2026-04-26-auto-fill-vision-flow-for-new-clients.md @@ -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. diff --git a/web/service/inbound.go b/web/service/inbound.go index 7a2e66ff..edbb1186 100644 --- a/web/service/inbound.go +++ b/web/service/inbound.go @@ -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 == "" { diff --git a/web/service/inbound_flow_autofill_test.go b/web/service/inbound_flow_autofill_test.go new file mode 100644 index 00000000..50de83b2 --- /dev/null +++ b/web/service/inbound_flow_autofill_test.go @@ -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") + } +}