Files
3x-ui/internal/web/service/inbound_flow_restore.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

122 lines
3.4 KiB
Go

package service
import (
"encoding/json"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"gorm.io/gorm"
)
const visionFlow = "xtls-rprx-vision"
// restoreVisionFlowForEligibleInbound re-adds the XTLS Vision flow to a VLESS
// inbound's clients that lost it earlier.
//
// clientWithInboundFlow strips Vision from a client whenever the target inbound
// is not flow-eligible at write time (e.g. an XHTTP inbound before its vlessenc
// encryption is set). Nothing restored the flow when the inbound later became
// eligible — an inbound edit stores its settings verbatim and never re-gates the
// clients — so enabling encryption on an existing XHTTP inbound left every
// client without flow, and the share links/subscriptions dropped it.
//
// This runs on the now-final inbound settings: when the inbound IS flow-eligible
// it sets flow=Vision on each client that currently has no flow but whose
// intended flow (its flow_override on a sibling inbound, via EffectiveFlowsByEmails)
// is Vision. It never invents a flow for a client that has none anywhere, and it
// never overwrites an explicit non-empty flow. Returns the rewritten settings
// JSON and whether anything changed.
func (s *InboundService) restoreVisionFlowForEligibleInbound(tx *gorm.DB, settings, streamSettings string, protocol model.Protocol) (string, bool) {
if protocol != model.VLESS {
return settings, false
}
if !inboundCanEnableTlsFlow(string(protocol), streamSettings, settings) {
return settings, false
}
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
}
// Collect empty-flow clients, then resolve their intended flow in one query.
emails := make([]string, 0, len(clients))
for i := range clients {
cm, ok := clients[i].(map[string]any)
if !ok {
continue
}
if flow, _ := cm["flow"].(string); flow != "" {
continue // respect an explicit flow (Vision or otherwise)
}
if email, _ := cm["email"].(string); email != "" {
emails = append(emails, email)
}
}
if len(emails) == 0 {
return settings, false
}
intended, err := s.clientService.EffectiveFlowsByEmails(tx, emails)
if err != nil {
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 != "" {
continue
}
email, _ := cm["email"].(string)
if intended[email] != visionFlow {
continue
}
cm["flow"] = visionFlow
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
}
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
}