diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 53178b385..36463014e 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -1935,6 +1935,10 @@ }, "type": "array" }, + "disableFlow": { + "example": false, + "type": "boolean" + }, "down": { "description": "Download traffic in bytes", "format": "int64", @@ -2064,6 +2068,7 @@ }, "required": [ "clientStats", + "disableFlow", "down", "enable", "expiryTime", @@ -3328,6 +3333,7 @@ "uuid": "e18c9a96-71bf-48d4-933f-8b9a46d4290c" } ], + "disableFlow": false, "down": 0, "enable": true, "expiryTime": 0, diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index d4c4de06d..16fe4094e 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -484,6 +484,7 @@ export const EXAMPLES: Record = { "uuid": "e18c9a96-71bf-48d4-933f-8b9a46d4290c" } ], + "disableFlow": false, "down": 0, "enable": true, "expiryTime": 0, diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index 98c330a75..53acd2177 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -1909,6 +1909,10 @@ export const SCHEMAS: Record = { }, "type": "array" }, + "disableFlow": { + "example": false, + "type": "boolean" + }, "down": { "description": "Download traffic in bytes", "format": "int64", @@ -2038,6 +2042,7 @@ export const SCHEMAS: Record = { }, "required": [ "clientStats", + "disableFlow", "down", "enable", "expiryTime", diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index 4473a37bc..bf02db678 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -452,6 +452,7 @@ export interface HostGroup { export interface Inbound { clientStats: ClientTraffic[]; + disableFlow: boolean; down: number; enable: boolean; expiryTime: number; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index 3ccb952b9..74c5d2454 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -487,6 +487,7 @@ export type HostGroup = z.infer; export const InboundSchema = z.object({ clientStats: z.array(z.lazy(() => ClientTrafficSchema)), + disableFlow: z.boolean(), down: z.number().int(), enable: z.boolean(), expiryTime: z.number().int(), diff --git a/frontend/src/lib/xray/inbound-form-adapter.ts b/frontend/src/lib/xray/inbound-form-adapter.ts index cebc15087..77911e515 100644 --- a/frontend/src/lib/xray/inbound-form-adapter.ts +++ b/frontend/src/lib/xray/inbound-form-adapter.ts @@ -47,6 +47,7 @@ export interface RawInboundRow { shareAddrStrategy?: string; shareAddr?: string; subSortIndex?: number; + disableFlow?: boolean; clientStats?: unknown; } @@ -75,6 +76,7 @@ export interface WireInboundPayload { shareAddrStrategy: ShareAddrStrategy; shareAddr: string; subSortIndex: number; + disableFlow: boolean; } function coerceJsonObject(value: unknown): Record { @@ -210,6 +212,7 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues { shareAddrStrategy: coerceShareAddrStrategy(row.shareAddrStrategy), shareAddr: row.shareAddr ?? '', subSortIndex: Math.max(1, row.subSortIndex ?? 1), + disableFlow: row.disableFlow ?? false, protocol, settings, } as InboundFormValues; @@ -361,6 +364,7 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP shareAddrStrategy: values.shareAddrStrategy, shareAddr: values.shareAddr, subSortIndex: values.subSortIndex, + disableFlow: values.disableFlow, }; if (values.nodeId != null) payload.nodeId = values.nodeId; return payload; diff --git a/frontend/src/models/dbinbound.ts b/frontend/src/models/dbinbound.ts index 57a6b9bc1..2e5cc3753 100644 --- a/frontend/src/models/dbinbound.ts +++ b/frontend/src/models/dbinbound.ts @@ -44,6 +44,7 @@ export type DBInboundInit = Partial<{ shareAddrStrategy: string; shareAddr: string; subSortIndex: number; + disableFlow: boolean; originNodeGuid: string; fallbackParent: FallbackParentRef | null; }>; @@ -92,6 +93,7 @@ export class DBInbound { shareAddrStrategy: string; shareAddr: string; subSortIndex: number; + disableFlow: boolean; originNodeGuid: string; fallbackParent: FallbackParentRef | null; @@ -122,6 +124,7 @@ export class DBInbound { this.shareAddrStrategy = "node"; this.shareAddr = ""; this.subSortIndex = 1; + this.disableFlow = false; this.originNodeGuid = ""; this.fallbackParent = null; if (data == null) { diff --git a/frontend/src/pages/inbounds/form/InboundFormModal.tsx b/frontend/src/pages/inbounds/form/InboundFormModal.tsx index 53c33f63a..ee6522251 100644 --- a/frontend/src/pages/inbounds/form/InboundFormModal.tsx +++ b/frontend/src/pages/inbounds/form/InboundFormModal.tsx @@ -430,6 +430,9 @@ export default function InboundFormModal({ if (!NODE_ELIGIBLE_PROTOCOLS[next]) { setV('nodeId', null); } + if (next !== Protocols.VLESS) { + setV('disableFlow', false); + } if (next === Protocols.HYSTERIA) { setV('streamSettings', { network: 'hysteria', @@ -581,6 +584,16 @@ export default function InboundFormModal({ + {protocol === Protocols.VLESS && ( + + + + )} + ; diff --git a/frontend/src/test/inbound-form-adapter.test.ts b/frontend/src/test/inbound-form-adapter.test.ts index c949d095f..b70c48832 100644 --- a/frontend/src/test/inbound-form-adapter.test.ts +++ b/frontend/src/test/inbound-form-adapter.test.ts @@ -6,6 +6,7 @@ import { formValuesToWirePayload, type RawInboundRow, } from '@/lib/xray/inbound-form-adapter'; +import { DBInbound, type DBInboundInit } from '@/models/dbinbound'; import { InboundDbFieldsSchema, InboundFormSchema } from '@/schemas/forms/inbound-form'; import { normalizeXhttpForWire } from '@/lib/xray/stream-wire-normalize'; import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt'; @@ -287,6 +288,35 @@ describe('formValuesToWirePayload', () => { }); }); +describe('disableFlow', () => { + it('DBInbound constructor preserves disableFlow from the API row', () => { + expect(new DBInbound({ disableFlow: true }).disableFlow).toBe(true); + expect(new DBInbound({ disableFlow: false }).disableFlow).toBe(false); + }); + + it('DBInbound defaults disableFlow to false when the API omits it', () => { + expect(new DBInbound({ protocol: 'vless' }).disableFlow).toBe(false); + expect(new DBInbound().disableFlow).toBe(false); + }); + + it('rawInboundToFormValues reads disableFlow and defaults to false', () => { + expect(rawInboundToFormValues({ ...vlessRow, disableFlow: true }).disableFlow).toBe(true); + expect(rawInboundToFormValues(vlessRow).disableFlow).toBe(false); + }); + + it('formValuesToWirePayload includes disableFlow', () => { + const values = rawInboundToFormValues({ ...vlessRow, disableFlow: true }); + expect(formValuesToWirePayload(values).disableFlow).toBe(true); + }); + + it('disableFlow survives raw → DBInbound → values → payload (the edit round-trip)', () => { + const db = new DBInbound({ ...vlessRow, disableFlow: true } as unknown as DBInboundInit); + const values = rawInboundToFormValues(db as unknown as RawInboundRow); + const payload = formValuesToWirePayload(values); + expect(payload.disableFlow).toBe(true); + }); +}); + describe('subSortIndex', () => { it('rawInboundToFormValues defaults to 1 when field is absent', () => { const values = rawInboundToFormValues({ ...vlessRow, subSortIndex: undefined }); diff --git a/internal/database/model/model.go b/internal/database/model/model.go index 5b8d36a27..fe5aa6d7a 100644 --- a/internal/database/model/model.go +++ b/internal/database/model/model.go @@ -70,6 +70,8 @@ type Inbound struct { ShareAddrStrategy string `json:"shareAddrStrategy" form:"shareAddrStrategy" gorm:"column:share_addr_strategy;default:node" validate:"omitempty,oneof=node listen custom"` ShareAddr string `json:"shareAddr" form:"shareAddr" gorm:"column:share_addr"` + DisableFlow bool `json:"disableFlow" form:"disableFlow" gorm:"column:disable_flow;default:false" example:"false"` + // OriginNodeGuid is the panelGuid of the node that physically hosts this // inbound, propagated up across hops (#4983). Empty for an inbound that // lives on this panel's own xray; set to the originating node's GUID when diff --git a/internal/sub/clash_service.go b/internal/sub/clash_service.go index e6f4b90b5..d0e75ec34 100644 --- a/internal/sub/clash_service.go +++ b/internal/sub/clash_service.go @@ -248,7 +248,7 @@ func (s *SubClashService) buildProxy(subReq *SubService, inbound *model.Inbound, proxy["uuid"] = applyVlessRoute(client.ID, hostVlessRoute(ep)) inboundSettings := subReq.linkSettings(inbound) streamSecurity, _ := stream["security"].(string) - if client.Flow != "" && vlessFlowAllowed(network, streamSecurity, inboundSettings) { + if client.Flow != "" && !inbound.DisableFlow && vlessFlowAllowed(network, streamSecurity, inboundSettings) { proxy["flow"] = client.Flow } if encryption, ok := inboundSettings["encryption"].(string); ok { diff --git a/internal/sub/clash_service_test.go b/internal/sub/clash_service_test.go index fb6f626cf..1147fea4c 100644 --- a/internal/sub/clash_service_test.go +++ b/internal/sub/clash_service_test.go @@ -255,6 +255,31 @@ func TestBuildProxy_VLESSFlowXhttpRealityVlessenc(t *testing.T) { } } +func TestBuildProxy_VLESSFlowSuppressedByDisableFlow(t *testing.T) { + svc := &SubClashService{SubService: &SubService{}} + inbound := &model.Inbound{ + Listen: "203.0.113.1", + Port: 443, + Protocol: model.VLESS, + Remark: "disabled-flow", + Settings: `{"encryption":"` + testMlkemEncryption + `"}`, + DisableFlow: true, + } + client := model.Client{ID: "11111111-2222-4333-8444-555555555555", Flow: "xtls-rprx-vision"} + stream := map[string]any{ + "network": "xhttp", + "xhttpSettings": map[string]any{"path": "/", "mode": "auto"}, + "security": "reality", + "realitySettings": map[string]any{"publicKey": "pub", "serverName": "example.com", "shortId": "abcd"}, + } + + proxy := svc.buildProxy(svc.SubService, inbound, client, stream, nil) + + if _, ok := proxy["flow"]; ok { + t.Fatalf("DisableFlow inbound must not carry a flow in the Clash proxy: %#v", proxy) + } +} + func TestBuildProxy_VLESSFlowDroppedWithoutVisionSupport(t *testing.T) { svc := &SubClashService{SubService: &SubService{}} inbound := &model.Inbound{ diff --git a/internal/sub/json_service.go b/internal/sub/json_service.go index cbd8ddb90..92338c3cc 100644 --- a/internal/sub/json_service.go +++ b/internal/sub/json_service.go @@ -429,7 +429,7 @@ func (s *SubJsonService) genVless(subReq *SubService, inbound *model.Inbound, st "encryption": encryption, "level": 8, } - if client.Flow != "" { + if client.Flow != "" && !inbound.DisableFlow { settings["flow"] = client.Flow } outbound.Settings = settings diff --git a/internal/sub/json_service_test.go b/internal/sub/json_service_test.go index de34030d3..392e30fc4 100644 --- a/internal/sub/json_service_test.go +++ b/internal/sub/json_service_test.go @@ -133,6 +133,16 @@ func TestSubJsonServiceVlessFlattened(t *testing.T) { } } +func TestSubJsonServiceVlessFlowSuppressedByDisableFlow(t *testing.T) { + inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`, DisableFlow: true} + client := model.Client{ID: "uuid-1", Flow: "xtls-rprx-vision"} + + settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVless(&SubService{}, inbound, nil, client, "")) + if _, ok := settings["flow"]; ok { + t.Fatalf("DisableFlow inbound must not carry a flow in the JSON outbound: %#v", settings) + } +} + func TestSubJsonServiceVmessFlattened(t *testing.T) { inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VMESS, Settings: `{}`} client := model.Client{ID: "uuid-2"} diff --git a/internal/sub/service.go b/internal/sub/service.go index 828287efa..b72c7f266 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -822,7 +822,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { default: params["security"] = "none" } - if len(client.Flow) > 0 && vlessFlowAllowed(streamNetwork, security, settings) { + if len(client.Flow) > 0 && !inbound.DisableFlow && vlessFlowAllowed(streamNetwork, security, settings) { params["flow"] = client.Flow } @@ -872,7 +872,7 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string applyShareTLSParams(stream, params) case "reality": applyShareRealityParams(stream, params, subKey(client)) - if streamNetwork == "tcp" && len(client.Flow) > 0 { + if streamNetwork == "tcp" && len(client.Flow) > 0 && !inbound.DisableFlow { params["flow"] = client.Flow } default: diff --git a/internal/sub/service_flow_test.go b/internal/sub/service_flow_test.go index 250c9e60d..030d03fca 100644 --- a/internal/sub/service_flow_test.go +++ b/internal/sub/service_flow_test.go @@ -81,6 +81,16 @@ func TestGenVlessLink_NoFlowXhttpRealityWithoutVlessenc(t *testing.T) { } } +func TestGenVlessLink_DisableFlowSuppressesFlow(t *testing.T) { + s := &SubService{} + ib := flowTestInbound(xhttpRealityStream, testMlkemEncryption) + ib.DisableFlow = true + link := s.genVlessLink(ib, "user") + if strings.Contains(link, "flow=") { + t.Fatalf("DisableFlow inbound must not carry a flow even when the transport is capable, got %q", link) + } +} + func TestGenVlessLink_FlowTcpRealityStillWorks(t *testing.T) { stream := `{ "network": "tcp", diff --git a/internal/web/runtime/remote.go b/internal/web/runtime/remote.go index a851e66bf..594af642d 100644 --- a/internal/web/runtime/remote.go +++ b/internal/web/runtime/remote.go @@ -819,6 +819,7 @@ func wireInbound(ib *model.Inbound, remoteNodeID int) url.Values { } v.Set("shareAddrStrategy", shareAddrStrategy) v.Set("shareAddr", ib.ShareAddr) + v.Set("disableFlow", strconv.FormatBool(ib.DisableFlow)) if ib.TrafficReset != "" { v.Set("trafficReset", ib.TrafficReset) } diff --git a/internal/web/runtime/remote_test.go b/internal/web/runtime/remote_test.go index 35b06b55b..a97073006 100644 --- a/internal/web/runtime/remote_test.go +++ b/internal/web/runtime/remote_test.go @@ -185,6 +185,17 @@ func TestWireInboundIncludesShareAddressFields(t *testing.T) { } } +// A node that does not mirror DisableFlow re-injects Vision into its own xray +// config and share links, undoing the opt-out on every multi-node deployment. +func TestWireInboundCarriesDisableFlow(t *testing.T) { + if got := wireInbound(&model.Inbound{DisableFlow: true}, 0).Get("disableFlow"); got != "true" { + t.Fatalf("disableFlow = %q, want true", got) + } + if got := wireInbound(&model.Inbound{}, 0).Get("disableFlow"); got != "false" { + t.Fatalf("disableFlow = %q, want false", got) + } +} + func TestRemoteHTTPClientEgressProxy(t *testing.T) { // OutboundTag + a resolver → a dedicated proxy client (not the shared default). withTag := NewRemote(&model.Node{Id: 1, Scheme: "https", TlsVerifyMode: "verify", OutboundTag: "warp"}, stubEgress{url: "socks5://127.0.0.1:1080"}) diff --git a/internal/web/service/client_bulk.go b/internal/web/service/client_bulk.go index 02350b68f..230ca05c5 100644 --- a/internal/web/service/client_bulk.go +++ b/internal/web/service/client_bulk.go @@ -590,7 +590,8 @@ func (s *ClientService) bulkAdjustInboundClients( // resolve it once. Clearing flow is always allowed; setting a vision flow // is only honored on an inbound that can carry it. flowEligible := flow == bulkFlowClear || - inboundCanEnableTlsFlow(string(oldInbound.Protocol), oldInbound.StreamSettings, oldInbound.Settings) + (!oldInbound.DisableFlow && + inboundCanEnableTlsFlow(string(oldInbound.Protocol), oldInbound.StreamSettings, oldInbound.Settings)) interfaceClients, _ := settings["clients"].([]any) foundEmails := map[string]bool{} diff --git a/internal/web/service/client_crud.go b/internal/web/service/client_crud.go index ec610fddb..e96346eeb 100644 --- a/internal/web/service/client_crud.go +++ b/internal/web/service/client_crud.go @@ -196,7 +196,7 @@ func mtprotoDomainFromSettings(settings string) string { } func clientWithInboundFlow(c model.Client, ib *model.Inbound) model.Client { - if !inboundCanEnableTlsFlow(string(ib.Protocol), ib.StreamSettings, ib.Settings) { + if ib.DisableFlow || !inboundCanEnableTlsFlow(string(ib.Protocol), ib.StreamSettings, ib.Settings) { c.Flow = "" } return c diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index 4da26afd1..0f6385215 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -344,9 +344,10 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error) ShareAddrStrategy string `gorm:"column:share_addr_strategy"` NodeId *int `gorm:"column:node_id"` NodeAddress string `gorm:"column:node_address"` + DisableFlow bool `gorm:"column:disable_flow"` } err := db.Table("inbounds"). - Select("inbounds.id, inbounds.remark, inbounds.tag, inbounds.protocol, inbounds.port, inbounds.enable, inbounds.stream_settings, inbounds.settings, inbounds.listen, inbounds.share_addr, inbounds.share_addr_strategy, inbounds.node_id, COALESCE(nodes.address, '') AS node_address"). + Select("inbounds.id, inbounds.remark, inbounds.tag, inbounds.protocol, inbounds.port, inbounds.enable, inbounds.stream_settings, inbounds.settings, inbounds.listen, inbounds.share_addr, inbounds.share_addr_strategy, inbounds.node_id, COALESCE(nodes.address, '') AS node_address, inbounds.disable_flow"). Joins("LEFT JOIN nodes ON nodes.id = inbounds.node_id"). Where("inbounds.user_id = ?", userId). Order("inbounds.id ASC"). @@ -368,7 +369,7 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error) Protocol: r.Protocol, Port: r.Port, Enable: r.Enable, - TlsFlowCapable: inboundCanEnableTlsFlow(r.Protocol, r.StreamSettings, r.Settings), + TlsFlowCapable: !r.DisableFlow && inboundCanEnableTlsFlow(r.Protocol, r.StreamSettings, r.Settings), SsMethod: inboundShadowsocksMethod(r.Protocol, r.Settings), WgPublicKey: wgPublicKey, WgMtu: wgMtu, @@ -954,6 +955,15 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo return inbound, false, common.NewError("Duplicate email:", existEmail) } + if inbound.DisableFlow { + if stripped, changed := stripClientFlows(inbound.Settings); changed { + inbound.Settings = stripped + } + for i := range clients { + clients[i].Flow = "" + } + } + // Ensure created_at and updated_at on clients in settings if len(clients) > 0 { var settings map[string]any @@ -1506,8 +1516,14 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, // VLESS inbound just became flow-eligible (e.g. vlessenc was enabled on an // XHTTP inbound), restore Vision for clients whose intended flow is Vision // but was stripped while the inbound was ineligible. - if restored, changed := s.restoreVisionFlowForEligibleInbound(tx, inbound.Settings, inbound.StreamSettings, inbound.Protocol); changed { - inbound.Settings = restored + if !inbound.DisableFlow { + if restored, changed := s.restoreVisionFlowForEligibleInbound(tx, inbound.Settings, inbound.StreamSettings, inbound.Protocol); changed { + inbound.Settings = restored + } + } else { + if stripped, changed := stripClientFlows(inbound.Settings); changed { + inbound.Settings = stripped + } } oldInbound.Total = inbound.Total @@ -1520,6 +1536,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, oldInbound.Listen = inbound.Listen oldInbound.Port = inbound.Port oldInbound.Protocol = inbound.Protocol + oldInbound.DisableFlow = inbound.DisableFlow oldInbound.Settings = inbound.Settings oldInbound.StreamSettings = inbound.StreamSettings oldInbound.Sniffing = inbound.Sniffing diff --git a/internal/web/service/inbound_clients.go b/internal/web/service/inbound_clients.go index 7e6a1e511..8957510b0 100644 --- a/internal/web/service/inbound_clients.go +++ b/internal/web/service/inbound_clients.go @@ -221,6 +221,7 @@ func (s *InboundService) buildTargetClientFromSource(source model.Client, target case model.VLESS: target.ID = s.generateRandomCredential(targetProtocol) if (flow == "xtls-rprx-vision" || flow == "xtls-rprx-vision-udp443") && + !targetInbound.DisableFlow && inboundCanEnableTlsFlow(string(targetProtocol), targetInbound.StreamSettings, targetInbound.Settings) { target.Flow = flow } diff --git a/internal/web/service/inbound_disable_flow_test.go b/internal/web/service/inbound_disable_flow_test.go new file mode 100644 index 000000000..14a5c132b --- /dev/null +++ b/internal/web/service/inbound_disable_flow_test.go @@ -0,0 +1,211 @@ +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) + } +} diff --git a/internal/web/service/inbound_flow_restore.go b/internal/web/service/inbound_flow_restore.go index e1accf89b..bf96561c1 100644 --- a/internal/web/service/inbound_flow_restore.go +++ b/internal/web/service/inbound_flow_restore.go @@ -88,3 +88,34 @@ func (s *InboundService) restoreVisionFlowForEligibleInbound(tx *gorm.DB, settin } return string(out), true } + +func stripClientFlows(settings string) (string, bool) { + var parsed map[string]any + if err := json.Unmarshal([]byte(settings), &parsed); err != nil { + return settings, false + } + clients, ok := parsed["clients"].([]any) + if !ok || len(clients) == 0 { + return settings, false + } + changed := false + for i := range clients { + cm, ok := clients[i].(map[string]any) + if !ok { + continue + } + if flow, _ := cm["flow"].(string); flow != "" { + cm["flow"] = "" + clients[i] = cm + changed = true + } + } + if !changed { + return settings, false + } + out, err := json.MarshalIndent(parsed, "", " ") + if err != nil { + return settings, false + } + return string(out), true +} diff --git a/internal/web/service/inbound_migration.go b/internal/web/service/inbound_migration.go index 37e1a5d1a..a5bedd7f5 100644 --- a/internal/web/service/inbound_migration.go +++ b/internal/web/service/inbound_migration.go @@ -301,6 +301,9 @@ func (s *InboundService) MigrationRestoreVisionFlow() { return } for _, ib := range inbounds { + if ib.DisableFlow { + continue + } restored, changed := s.restoreVisionFlowForEligibleInbound(nil, ib.Settings, ib.StreamSettings, ib.Protocol) if !changed { continue diff --git a/internal/web/service/inbound_node.go b/internal/web/service/inbound_node.go index 1736f100f..ec6b1aae5 100644 --- a/internal/web/service/inbound_node.go +++ b/internal/web/service/inbound_node.go @@ -627,6 +627,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi Up: snapIb.Up, Down: snapIb.Down, ShareAddrStrategy: "node", + DisableFlow: snapIb.DisableFlow, } if err := tx.Create(&newIb).Error; err != nil { logger.Warningf("setRemoteTraffic: create central inbound for tag %q failed: %v", snapIb.Tag, err) diff --git a/internal/web/service/xray.go b/internal/web/service/xray.go index 45f3eebaf..1a353a7a8 100644 --- a/internal/web/service/xray.go +++ b/internal/web/service/xray.go @@ -204,6 +204,9 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) { if flow == "xtls-rprx-vision-udp443" { flow = "xtls-rprx-vision" } + if inbound.DisableFlow { + flow = "" + } entry := map[string]any{"email": c.Email} switch inbound.Protocol { case model.VLESS: diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index d957c21bc..35ccdb815 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -617,6 +617,8 @@ "shareAddrHelp": "يُستخدم فقط عندما تكون استراتيجية عنوان المشاركة مخصصة. أدخل اسم مضيف أو عنوان IP بدون بروتوكول أو منفذ.", "subSortIndex": "ترتيب الروابط في الاشتراك", "subSortIndexHelp": "موضع روابط هذا الوارد في مخرجات الاشتراك (صفحة الاشتراك وتطبيقات العملاء). القيم الأقل تظهر أولاً، والقيم المتساوية تحافظ على ترتيب الإنشاء. لا يؤثر على قائمة الواردات في اللوحة.", + "disableFlow": "تعطيل تدفق XTLS", + "disableFlowHelp": "استثناء هذا الـ inbound من الحقن التلقائي لـ xtls-rprx-vision، حتى عندما يكون النقل قادرًا على الـ flow (مثل inbound من نوع XHTTP عبر نفق مع تشفير VLESS). يحتفظ العملاء بـ Vision على باقي الـ inbounds القادرة ضمن نفس الاشتراك. لـ VLESS فقط.", "shareAddrStrategyOptions": { "node": "عنوان العقدة", "listen": "عنوان استماع الوارد", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index 2226d0392..c2adba542 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -638,6 +638,8 @@ "shareAddrHelp": "Used only when the share address strategy is Custom. Enter a host or IP without a scheme or port.", "subSortIndex": "Subscription sort order", "subSortIndexHelp": "Position of this inbound's links in subscription output (sub page and client apps). Lower values come first; equal values keep creation order. Does not affect the panel inbound list.", + "disableFlow": "Disable XTLS flow", + "disableFlowHelp": "Opt this inbound out of automatic xtls-rprx-vision injection, even when its transport is flow-capable (e.g. a tunneled XHTTP inbound with VLESS encryption). Clients keep Vision on your other capable inbounds in the same subscription. VLESS only.", "shareAddrStrategyOptions": { "node": "Node address", "listen": "Inbound listen", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index 6ed53b0f6..af144de64 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -638,6 +638,8 @@ "shareAddrHelp": "Solo se usa cuando la estrategia de dirección para compartir es Personalizada. Introduce un host o IP sin esquema ni puerto.", "subSortIndex": "Orden en la suscripción", "subSortIndexHelp": "Posición de los enlaces de esta entrada en la salida de la suscripción (página de suscripción y apps cliente). Los valores más bajos van primero; con valores iguales se mantiene el orden de creación. No afecta a la lista de entradas del panel.", + "disableFlow": "Desactivar el flujo XTLS", + "disableFlowHelp": "Excluye este inbound de la inyección automática de xtls-rprx-vision, incluso cuando su transporte admite flow (p. ej. un inbound XHTTP tunelizado con cifrado VLESS). Los clientes mantienen Vision en tus demás inbounds compatibles de la misma suscripción. Solo VLESS.", "shareAddrStrategyOptions": { "node": "Dirección del nodo", "listen": "Dirección de escucha del inbound", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index 4d6304fb3..b64ccaa3f 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -638,6 +638,8 @@ "shareAddrHelp": "فقط زمانی استفاده می‌شود که راهبرد آدرس اشتراک‌گذاری روی سفارشی باشد. میزبان یا IP را بدون طرح و پورت وارد کنید.", "subSortIndex": "ترتیب در اشتراک", "subSortIndexHelp": "جایگاه لینک‌های این ورودی در خروجی اشتراک (صفحه اشتراک و برنامه‌های کلاینت). مقدار کمتر اول می‌آید و مقدارهای برابر ترتیب ایجاد را حفظ می‌کنند. روی فهرست ورودی‌های پنل تأثیری ندارد.", + "disableFlow": "غیرفعال‌کردن جریان XTLS", + "disableFlowHelp": "این inbound را از تزریق خودکار xtls-rprx-vision کنار بگذارید، حتی وقتی ترنسپورت آن از flow پشتیبانی می‌کند (مثلاً یک inbound از نوع XHTTP تونل‌شده با رمزنگاری VLESS). کلاینت‌ها Vision را روی سایر inboundهای سازگار در همان اشتراک حفظ می‌کنند. فقط برای VLESS.", "shareAddrStrategyOptions": { "node": "آدرس نود", "listen": "آدرس شنود ورودی", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index 3f02eb861..02e1addb8 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -617,6 +617,8 @@ "shareAddrHelp": "Hanya digunakan saat strategi alamat berbagi adalah Kustom. Masukkan host atau IP tanpa skema atau port.", "subSortIndex": "Urutan dalam langganan", "subSortIndexHelp": "Posisi tautan inbound ini dalam keluaran langganan (halaman langganan dan aplikasi klien). Nilai lebih kecil tampil lebih dulu; nilai sama mempertahankan urutan pembuatan. Tidak memengaruhi daftar inbound di panel.", + "disableFlow": "Nonaktifkan flow XTLS", + "disableFlowHelp": "Kecualikan inbound ini dari injeksi otomatis xtls-rprx-vision, meskipun transport-nya mendukung flow (mis. inbound XHTTP yang dituneling dengan enkripsi VLESS). Klien tetap memakai Vision pada inbound lain yang mendukung dalam langganan yang sama. Hanya VLESS.", "shareAddrStrategyOptions": { "node": "Alamat node", "listen": "Alamat listen inbound", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index dd57c7aa2..990046e0a 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -638,6 +638,8 @@ "shareAddrHelp": "共有アドレス戦略がカスタムの場合のみ使用されます。スキームやポートを含めずにホスト名またはIPを入力してください。", "subSortIndex": "サブスクリプションでの並び順", "subSortIndexHelp": "サブスクリプション出力(サブスクリプションページおよびクライアントアプリ)におけるこのインバウンドのリンクの位置。値が小さいほど先頭に表示され、同じ値の場合は作成順が維持されます。パネルのインバウンド一覧には影響しません。", + "disableFlow": "XTLS フローを無効化", + "disableFlowHelp": "トランスポートが flow に対応している場合でも(例: VLESS 暗号化付きのトンネル化された XHTTP インバウンド)、このインバウンドを xtls-rprx-vision の自動付与から除外します。クライアントは同じサブスクリプション内の他の対応インバウンドでは Vision を維持します。VLESS のみ。", "shareAddrStrategyOptions": { "node": "ノードアドレス", "listen": "インバウンドのリッスンアドレス", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index a338ba4a0..e1f225791 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -638,6 +638,8 @@ "shareAddrHelp": "Usado apenas quando a estratégia de endereço de compartilhamento é Personalizada. Informe um host ou IP sem esquema nem porta.", "subSortIndex": "Ordem na assinatura", "subSortIndexHelp": "Posição dos links desta entrada na saída da assinatura (página de assinatura e aplicativos cliente). Valores menores vêm primeiro; valores iguais mantêm a ordem de criação. Não afeta a lista de entradas do painel.", + "disableFlow": "Desativar o flow XTLS", + "disableFlowHelp": "Exclui este inbound da injeção automática de xtls-rprx-vision, mesmo quando o transporte suporta flow (ex.: um inbound XHTTP tunelado com criptografia VLESS). Os clientes mantêm o Vision nos seus outros inbounds compatíveis da mesma assinatura. Somente VLESS.", "shareAddrStrategyOptions": { "node": "Endereço do nó", "listen": "Endereço de escuta do inbound", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index ca849826b..f3dc8c14b 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -638,6 +638,8 @@ "shareAddrHelp": "Используется только когда стратегия адреса для ссылок — пользовательская. Укажите хост или IP без схемы и порта.", "subSortIndex": "Порядок в подписке", "subSortIndexHelp": "Позиция ссылок этого входящего в выдаче подписки (страница подписки и клиентские приложения). Меньшие значения идут первыми; при равных значениях сохраняется порядок создания. Не влияет на список входящих в панели.", + "disableFlow": "Отключить поток XTLS", + "disableFlowHelp": "Исключить этот inbound из автоматического добавления xtls-rprx-vision, даже если его транспорт поддерживает flow (например, туннелированный XHTTP inbound с шифрованием VLESS). Клиенты сохраняют Vision на других подходящих inbound в той же подписке. Только VLESS.", "shareAddrStrategyOptions": { "node": "Адрес узла", "listen": "Адрес прослушивания inbound", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 38367064c..333d1d2bc 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -617,6 +617,8 @@ "shareAddrHelp": "Yalnızca paylaşım adresi stratejisi Özel olduğunda kullanılır. Şema veya port olmadan bir ana makine ya da IP girin.", "subSortIndex": "Abonelikte sıralama", "subSortIndexHelp": "Bu gelen bağlantının linklerinin abonelik çıktısındaki (abonelik sayfası ve istemci uygulamaları) konumu. Küçük değerler önce gelir; eşit değerlerde oluşturulma sırası korunur. Paneldeki gelen bağlantı listesini etkilemez.", + "disableFlow": "XTLS akışını devre dışı bırak", + "disableFlowHelp": "Taşıması flow destekliyor olsa bile (ör. VLESS şifrelemeli, tünellenmiş bir XHTTP inbound) bu inbound'u otomatik xtls-rprx-vision eklemenin dışında tut. İstemciler aynı abonelikteki diğer uygun inbound'larda Vision'ı korur. Yalnızca VLESS.", "shareAddrStrategyOptions": { "node": "Düğüm adresi", "listen": "Inbound dinleme adresi", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index f0209566e..a250fb524 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -617,6 +617,8 @@ "shareAddrHelp": "Використовується лише коли стратегія адреси поширення — користувацька. Введіть хост або IP без схеми та порту.", "subSortIndex": "Порядок у підписці", "subSortIndexHelp": "Позиція посилань цього вхідного у виводі підписки (сторінка підписки та клієнтські застосунки). Менші значення йдуть першими; за однакових значень зберігається порядок створення. Не впливає на список вхідних у панелі.", + "disableFlow": "Вимкнути потік XTLS", + "disableFlowHelp": "Виключити цей inbound з автоматичного додавання xtls-rprx-vision, навіть якщо його транспорт підтримує flow (наприклад, тунельований XHTTP inbound із шифруванням VLESS). Клієнти зберігають Vision на інших сумісних inbound у тій самій підписці. Лише VLESS.", "echSockopt": "ECH Sockopt", "echSockoptTip": "Параметри сокета для з'єднання, яке Xray використовує для отримання списку конфігурацій ECH (наприклад, спрямувати запит через вихідний dialerProxy). Залиште вимкненим, щоб використовувати типові значення.", "curvePreferences": "Налаштування кривих", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 998476745..899387dd7 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -638,6 +638,8 @@ "shareAddrHelp": "Chỉ dùng khi chiến lược địa chỉ chia sẻ là Tùy chỉnh. Nhập host hoặc IP không kèm giao thức hoặc cổng.", "subSortIndex": "Thứ tự trong gói đăng ký", "subSortIndexHelp": "Vị trí liên kết của inbound này trong nội dung gói đăng ký (trang đăng ký và ứng dụng khách). Giá trị nhỏ hơn xếp trước; giá trị bằng nhau giữ thứ tự tạo. Không ảnh hưởng đến danh sách inbound trong bảng điều khiển.", + "disableFlow": "Tắt luồng XTLS", + "disableFlowHelp": "Loại inbound này khỏi việc tự động thêm xtls-rprx-vision, ngay cả khi transport của nó hỗ trợ flow (ví dụ một inbound XHTTP đi qua tunnel với mã hóa VLESS). Client vẫn giữ Vision trên các inbound tương thích khác trong cùng subscription. Chỉ dành cho VLESS.", "shareAddrStrategyOptions": { "node": "Địa chỉ node", "listen": "Địa chỉ listen inbound", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 3a81d3e8e..6284c077d 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -637,6 +637,8 @@ "shareAddrHelp": "仅在分享地址策略为自定义时使用。填写不带协议和端口的域名或 IP。", "subSortIndex": "订阅排序", "subSortIndexHelp": "此入站的链接在订阅输出(订阅页面和客户端应用)中的位置。数值越小越靠前;数值相同时保持创建顺序。不影响面板中的入站列表。", + "disableFlow": "禁用 XTLS flow", + "disableFlowHelp": "让此入站跳过自动注入 xtls-rprx-vision,即使其传输支持 flow(例如启用 VLESS 加密的隧道化 XHTTP 入站)。客户端在同一订阅中的其他可用入站上仍保留 Vision。仅限 VLESS。", "shareAddrStrategyOptions": { "node": "节点地址", "listen": "入站监听地址", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 430d20dd3..f47cdb353 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -638,6 +638,8 @@ "shareAddrHelp": "僅在分享地址策略為自訂時使用。填寫不帶協定和連接埠的網域或 IP。", "subSortIndex": "訂閱排序", "subSortIndexHelp": "此入站的連結在訂閱輸出(訂閱頁面和客戶端應用)中的位置。數值越小越靠前;數值相同時保持建立順序。不影響面板中的入站清單。", + "disableFlow": "停用 XTLS flow", + "disableFlowHelp": "讓此入站略過自動注入 xtls-rprx-vision,即使其傳輸支援 flow(例如啟用 VLESS 加密的通道化 XHTTP 入站)。用戶端在同一訂閱中的其他可用入站上仍保留 Vision。僅限 VLESS。", "shareAddrStrategyOptions": { "node": "節點地址", "listen": "入站監聽地址",