Files
3x-ui/internal/web/service/inbound_disable_flow_test.go
T
Farhan Zare 930a0ed59d feat(inbound): DisableFlow — opt an inbound out of auto XTLS Vision (#5689) (#5698)
* feat(inbound): add DisableFlow to opt an inbound out of auto XTLS Vision

Adds an inbound-level DisableFlow flag so operators can suppress automatic
xtls-rprx-vision injection on a specific inbound even when its transport is
flow-capable — e.g. a tunneled/CDN-fronted XHTTP+vlessenc inbound where Vision
is not wanted, while keeping it on the same client's Reality inbounds.

When set, the inbound reports tlsFlowCapable=false, the write path clamps each
attached client's flow to empty (so flow_override stores ""), and share
links/subscriptions never carry the flow for it. The flag is panel-only
metadata and is never sent to xray.

Closes part of #5689.

* feat(inbound): DisableFlow toggle in the inbound form (frontend)

Wire the DisableFlow field through the form schema + adapters and add a
VLESS-gated switch in the inbound form, plus en-US strings. tsc --noEmit and
eslint pass.

* fix(inbound): honor DisableFlow in all emitters + on toggle; regen OpenAPI

Addresses review on #5690:
- Clash (clash_service.go) and JSON (json_service.go) subscription emitters now
  also skip the flow for a DisableFlow inbound — previously only the raw
  share-link path was gated, so those two still advertised it (blocking 1).
- UpdateInbound now strips any flow already stored on a DisableFlow inbound's
  clients (settings.clients[].flow + client_inbounds.flow_override) so xray and
  the subscription agree; otherwise toggling DisableFlow on an existing Vision
  client left xray expecting a flow the client no longer sends.
- Regenerated the OpenAPI + zod/types/examples artifacts for the new field and
  added an example tag (blocking 2; make gen-check is clean).
- Added Clash + JSON DisableFlow suppression tests alongside the raw-link one.

* fix(inbound): make DisableFlow durable, clamp on create, guard live config

Addresses the review + completeness audit on #5690:
- UpdateInbound now persists inbound.DisableFlow onto the saved row. It was
  only read to branch strip-vs-restore, so toggling the flag on an existing
  inbound never stuck and MigrationRestoreVisionFlow re-injected the flow — the
  exact #5689 path (editing a multi-inbound client's inbound) self-reverted.
- DBInbound (frontend) declares + initializes disableFlow so ObjectUtil
  .cloneProps carries the API value through; the edit Switch previously always
  read false and re-saving silently reverted the opt-out.
- AddInbound strips client flow (settings + parsed clients) when DisableFlow is
  set, so a created-disabled inbound never persists a flow xray would expect.
- GetXrayConfig forces flow="" for DisableFlow inbounds (VLESS + Trojan) as
  defense-in-depth, keeping the live config and the subscription in agreement.
- genTrojanLink share link honors DisableFlow too.
- Drop the dead explicit flow_override clear in UpdateInbound (SyncInbound
  rebuilds it from the stripped settings).
- Clear disableFlow in the inbound form when switching to a non-VLESS protocol.
- Add disableFlow/disableFlowHelp to the remaining 12 locales.

Tests: stripClientFlows unit cases; DB-backed AddInbound clamp; UpdateInbound
persist+strip+resist-restore regression (fails without the persist fix);
frontend DBInbound + adapter round-trip (fails without the model field).

* style(inbound): drop // line comments per repo CLAUDE.md

The DisableFlow work followed the surrounding code's commenting style; the repo
CLAUDE.md forbids // line comments in committed Go/TS. Remove the comments I
added (Go + frontend + tests) and regenerate OpenAPI/schemas, which drops the
generated field descriptions sourced from the Go doc comments. No behavior
change; full go test (service+sub, CGO) + frontend typecheck/vitest green;
golangci-lint clean on the changed files.

* fix(runtime): propagate disableFlow to nodes

Preserve the inbound DisableFlow flag when syncing inbounds across nodes and when recreating central records from remote traffic snapshots. This keeps multi-node deployments from reintroducing VLESS Vision flow in node configs and share links, and updates the related tests to cover the wired field and VLESS JSON generation.
2026-08-15 23:09:16 +02:00

212 lines
6.1 KiB
Go

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"
"gorm.io/gorm"
)
const visionTest = "xtls-rprx-vision"
func clientFlowsInSettings(t *testing.T, settings string) map[string]string {
t.Helper()
var parsed map[string]any
if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
t.Fatalf("parse settings: %v", err)
}
out := map[string]string{}
clients, _ := parsed["clients"].([]any)
for _, c := range clients {
cm, ok := c.(map[string]any)
if !ok {
continue
}
email, _ := cm["email"].(string)
flow, _ := cm["flow"].(string)
out[email] = flow
}
return out
}
func TestStripClientFlows(t *testing.T) {
cases := []struct {
name string
in string
wantChanged bool
wantFlows map[string]string
}{
{
name: "clears vision on all clients",
in: `{"clients":[{"email":"a","flow":"` + visionTest + `"},{"email":"b","flow":"` + visionTest + `"}]}`,
wantChanged: true,
wantFlows: map[string]string{"a": "", "b": ""},
},
{
name: "mixed flows: clears only the non-empty",
in: `{"clients":[{"email":"a","flow":"` + visionTest + `"},{"email":"b","flow":""}]}`,
wantChanged: true,
wantFlows: map[string]string{"a": "", "b": ""},
},
{
name: "no flows: unchanged",
in: `{"clients":[{"email":"a","flow":""},{"email":"b"}]}`,
wantChanged: false,
wantFlows: map[string]string{"a": "", "b": ""},
},
{
name: "no clients: unchanged",
in: `{"decryption":"none"}`,
wantChanged: false,
},
{
name: "malformed json: unchanged",
in: `{not json`,
wantChanged: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out, changed := stripClientFlows(tc.in)
if changed != tc.wantChanged {
t.Fatalf("changed = %v, want %v", changed, tc.wantChanged)
}
if !changed {
if out != tc.in {
t.Fatalf("unchanged input must be returned verbatim, got %q", out)
}
return
}
got := clientFlowsInSettings(t, out)
for email, want := range tc.wantFlows {
if got[email] != want {
t.Errorf("flow[%s] = %q, want %q", email, got[email], want)
}
}
})
}
}
func initFlowTestDB(t *testing.T) *gorm.DB {
t.Helper()
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() })
return database.GetDB()
}
func TestAddInbound_DisableFlowClampsClientFlow(t *testing.T) {
initFlowTestDB(t)
ibSvc := &InboundService{}
in := &model.Inbound{
Tag: "dis-add", Enable: true, Port: 52001, Protocol: model.VLESS,
StreamSettings: `{"network":"tcp","security":"reality"}`,
Settings: `{"clients":[{"id":"u1","email":"a@x","flow":"` + visionTest + `","subId":"s1","enable":true}]}`,
DisableFlow: true,
}
if _, _, err := ibSvc.AddInbound(in); err != nil {
t.Fatalf("AddInbound: %v", err)
}
got, err := ibSvc.GetInbound(in.Id)
if err != nil {
t.Fatalf("GetInbound: %v", err)
}
if !got.DisableFlow {
t.Error("DisableFlow not persisted on created inbound")
}
if f := clientFlowsInSettings(t, got.Settings)["a@x"]; f != "" {
t.Errorf("settings flow = %q, want empty (clamped at creation)", f)
}
list, err := ibSvc.clientService.ListForInbound(nil, in.Id)
if err != nil {
t.Fatalf("ListForInbound: %v", err)
}
if len(list) != 1 || list[0].Flow != "" {
t.Errorf("flow_override = %#v, want empty (xray must not expect Vision)", list)
}
}
func TestUpdateInbound_DisableFlowPersistsStripsAndResistsRestore(t *testing.T) {
db := initFlowTestDB(t)
ibSvc := &InboundService{}
cs := &ClientService{}
const email = "shared@x"
const uid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0d001"
sibling := &model.Inbound{
Tag: "sib", Enable: true, Port: 52101, Protocol: model.VLESS,
StreamSettings: `{"network":"tcp","security":"reality"}`,
Settings: `{"clients":[{"id":"` + uid + `","email":"` + email + `","flow":"` + visionTest + `","subId":"s1","enable":true}]}`,
}
if err := db.Create(sibling).Error; err != nil {
t.Fatalf("create sibling: %v", err)
}
sc, _ := ibSvc.GetClients(sibling)
if err := cs.SyncInbound(nil, sibling.Id, sc); err != nil {
t.Fatalf("sync sibling: %v", err)
}
target := &model.Inbound{
Tag: "tgt", Enable: true, Port: 52102, Protocol: model.VLESS,
StreamSettings: `{"network":"tcp","security":"reality"}`,
Settings: `{"clients":[{"id":"` + uid + `","email":"` + email + `","flow":"` + visionTest + `","subId":"s1","enable":true}]}`,
}
if err := db.Create(target).Error; err != nil {
t.Fatalf("create target: %v", err)
}
tc, _ := ibSvc.GetClients(target)
if err := cs.SyncInbound(nil, target.Id, tc); err != nil {
t.Fatalf("sync target: %v", err)
}
upd := *target
upd.DisableFlow = true
if _, _, err := ibSvc.UpdateInbound(&upd); err != nil {
t.Fatalf("UpdateInbound: %v", err)
}
reloaded, err := ibSvc.GetInbound(target.Id)
if err != nil {
t.Fatalf("GetInbound: %v", err)
}
if !reloaded.DisableFlow {
t.Fatal("DisableFlow did not persist through UpdateInbound (blocking regression)")
}
if f := clientFlowsInSettings(t, reloaded.Settings)["shared@x"]; f != "" {
t.Errorf("target settings flow = %q, want empty after disable", f)
}
list, err := cs.ListForInbound(nil, target.Id)
if err != nil {
t.Fatalf("ListForInbound(target): %v", err)
}
if len(list) != 1 || list[0].Flow != "" {
t.Errorf("target flow_override = %#v, want empty", list)
}
ibSvc.MigrationRestoreVisionFlow()
reloaded2, err := ibSvc.GetInbound(target.Id)
if err != nil {
t.Fatalf("GetInbound after restore: %v", err)
}
if f := clientFlowsInSettings(t, reloaded2.Settings)["shared@x"]; f != "" {
t.Errorf("after MigrationRestoreVisionFlow target flow = %q, want empty (must not self-revert)", f)
}
sList, err := cs.ListForInbound(nil, sibling.Id)
if err != nil {
t.Fatalf("ListForInbound(sibling): %v", err)
}
if len(sList) != 1 || sList[0].Flow != visionTest {
t.Errorf("sibling flow_override = %#v, want Vision preserved", sList)
}
}