Files
3x-ui/internal/sub/json_service_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

419 lines
15 KiB
Go

package sub
import (
"encoding/json"
"reflect"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
)
func hasDirectOutOutbound(svc *SubJsonService) bool {
for _, raw := range svc.defaultOutbounds {
var outbound map[string]any
if err := json.Unmarshal(raw, &outbound); err != nil {
continue
}
if outbound["tag"] == "direct_out" {
return true
}
}
return false
}
func outboundSettings(t *testing.T, raw []byte) map[string]any {
t.Helper()
var parsed map[string]any
if err := json.Unmarshal(raw, &parsed); err != nil {
t.Fatalf("failed to unmarshal outbound: %v", err)
}
settings, _ := parsed["settings"].(map[string]any)
if settings == nil {
t.Fatal("outbound has no settings")
}
return settings
}
func TestSubJsonServiceInjectsGlobalFinalMask(t *testing.T) {
finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello","length":"100-200","delay":"10-20"}}],"udp":[{"type":"noise","settings":{"noise":[{"type":"base64","packet":"SGVsbG8="}]}}],"quicParams":{"congestion":"bbr"}}`
svc := NewSubJsonService("", "", finalMask, nil)
if hasDirectOutOutbound(svc) {
t.Fatal("direct_out outbound must never be emitted")
}
stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "")
if _, ok := stream["sockopt"]; ok {
t.Fatal("legacy direct_out dialerProxy sockopt must never be set")
}
finalmask, _ := stream["finalmask"].(map[string]any)
if finalmask == nil {
t.Fatal("streamSettings is missing finalmask")
}
tcp, _ := finalmask["tcp"].([]any)
if len(tcp) != 1 {
t.Fatalf("tcp masks len = %d, want 1", len(tcp))
}
if first, _ := tcp[0].(map[string]any); first["type"] != "fragment" {
t.Fatalf("tcp[0] type = %v, want fragment", first["type"])
}
udp, _ := finalmask["udp"].([]any)
if len(udp) != 1 {
t.Fatalf("udp masks len = %d, want 1", len(udp))
}
quic, _ := finalmask["quicParams"].(map[string]any)
if quic == nil || quic["congestion"] != "bbr" {
t.Fatalf("quicParams missing/wrong: %#v", finalmask["quicParams"])
}
}
func TestSubJsonServiceMergesWithExistingFinalMask(t *testing.T) {
finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello"}}]}`
svc := NewSubJsonService("", "", finalMask, nil)
stream := svc.streamData(`{
"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}},
"finalmask":{"tcp":[{"type":"sudoku"}]}
}`, "")
finalmask, _ := stream["finalmask"].(map[string]any)
tcp, _ := finalmask["tcp"].([]any)
if len(tcp) != 2 {
t.Fatalf("tcp masks len = %d, want 2 (existing + global)", len(tcp))
}
a, _ := tcp[0].(map[string]any)
b, _ := tcp[1].(map[string]any)
if a["type"] != "sudoku" || b["type"] != "fragment" {
t.Fatalf("tcp masks = %#v, want existing sudoku then global fragment", tcp)
}
}
func TestSubJsonServiceNoFinalMaskWhenEmpty(t *testing.T) {
svc := NewSubJsonService("", "", "", nil)
stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "")
if _, ok := stream["finalmask"]; ok {
t.Fatal("no finalmask should be emitted when subJsonFinalMask is empty")
}
if _, ok := stream["sockopt"]; ok {
t.Fatal("legacy direct_out sockopt must never be set")
}
}
// xray-core parses tlsSettings.pinnedPeerCertSha256 as a comma-separated string;
// the JSON subscription must emit that form, not an array, or v2ray clients fail
// to import the config (#5401).
func TestSubJsonServicePinnedCertJoinedToString(t *testing.T) {
svc := NewSubJsonService("", "", "", nil)
stream := svc.streamData(`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"a.example.com","settings":{"pinnedPeerCertSha256":["aa11","bb22"]}}}`, "")
tls, _ := stream["tlsSettings"].(map[string]any)
if tls == nil {
t.Fatalf("tlsSettings missing: %#v", stream)
}
if got := tls["pinnedPeerCertSha256"]; got != "aa11,bb22" {
t.Fatalf("pinnedPeerCertSha256 = %#v, want comma-separated string \"aa11,bb22\"", got)
}
}
func TestSubJsonServiceVlessFlattened(t *testing.T) {
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`}
client := model.Client{ID: "uuid-1", Flow: "xtls-rprx-vision"}
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVless(&SubService{}, inbound, nil, client, ""))
if _, ok := settings["vnext"]; ok {
t.Fatal("vless outbound must not use vnext")
}
if settings["address"] != "1.2.3.4" || settings["id"] != "uuid-1" || settings["encryption"] != "none" || settings["flow"] != "xtls-rprx-vision" {
t.Fatalf("flat vless settings wrong: %#v", settings)
}
}
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"}
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVnext(inbound, nil, client, ""))
if _, ok := settings["vnext"]; ok {
t.Fatal("vmess outbound must not use vnext")
}
if settings["id"] != "uuid-2" || settings["security"] != "auto" {
t.Fatalf("flat vmess settings wrong: %#v", settings)
}
}
// Shadowsocks/Trojan outbounds must use the standard "servers" array so older
// bundled xray-cores (e.g. v2rayN) parse them; the flat top-level form only
// works on very recent xray-core.
func TestSubJsonServiceServerUsesServersArray(t *testing.T) {
trojan := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Trojan, Settings: `{}`}
client := model.Client{Password: "p4ss"}
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(&SubService{}, trojan, nil, client, ""))
server := firstServer(settings)
if server == nil {
t.Fatalf("trojan outbound must use a servers array, got: %#v", settings)
}
if server["password"] != "p4ss" || server["address"] != "1.2.3.4" {
t.Fatalf("trojan server entry wrong: %#v", server)
}
if _, ok := server["method"]; ok {
t.Fatalf("trojan must not carry method: %#v", server)
}
ss := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Shadowsocks, Settings: `{"method":"aes-256-gcm"}`}
ssSettings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(&SubService{}, ss, nil, client, ""))
ssServer := firstServer(ssSettings)
if ssServer == nil {
t.Fatalf("shadowsocks outbound must use a servers array, got: %#v", ssSettings)
}
if ssServer["method"] != "aes-256-gcm" {
t.Fatalf("shadowsocks server entry must carry method: %#v", ssServer)
}
}
func TestSubJsonServiceXmuxSuppressesGlobalMux(t *testing.T) {
globalMux := `{"enabled":true,"concurrency":8}`
svc := NewSubJsonService(globalMux, "", "", nil)
// When xmux is present in xhttpSettings, the per-inbound xmux handles
// multiplexing and the legacy outbound.Mux must NOT be set.
stream := `{"network":"xhttp","security":"tls","tlsSettings":{"serverName":"example.com"},"xhttpSettings":{"path":"/api","mode":"packet-up","xmux":{"maxConcurrency":"16-32"}}}`
parsed := svc.streamData(stream, "")
mux := globalMux
if xhttp, ok := parsed["xhttpSettings"].(map[string]any); ok {
if _, hasXmux := xhttp["xmux"]; hasXmux {
mux = ""
}
}
streamSettings, _ := json.Marshal(parsed)
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`}
client := model.Client{ID: "uuid-1"}
raw := svc.genVless(&SubService{}, inbound, streamSettings, client, mux)
var ob map[string]any
if err := json.Unmarshal(raw, &ob); err != nil {
t.Fatalf("unmarshal outbound: %v", err)
}
if _, has := ob["mux"]; has {
t.Fatal("outbound.Mux must NOT be set when per-inbound xmux is present")
}
// Verify xmux is still inside xhttpSettings in streamSettings.
ss, _ := ob["streamSettings"].(map[string]any)
if ss == nil {
t.Fatal("streamSettings missing from outbound")
}
xhttp, _ := ss["xhttpSettings"].(map[string]any)
if xhttp == nil {
t.Fatal("xhttpSettings missing from streamSettings")
}
xmux, _ := xhttp["xmux"].(map[string]any)
if xmux == nil {
t.Fatal("xmux missing from xhttpSettings — per-inbound xmux must survive streamData()")
}
if xmux["maxConcurrency"] != "16-32" {
t.Fatalf("xmux.maxConcurrency = %v, want 16-32", xmux["maxConcurrency"])
}
}
func TestSubJsonServiceGlobalMuxWhenNoXmux(t *testing.T) {
globalMux := `{"enabled":true,"concurrency":8}`
svc := NewSubJsonService(globalMux, "", "", nil)
// When no xmux is present, the global subJsonMux should be used.
stream := `{"network":"xhttp","security":"tls","tlsSettings":{"serverName":"example.com"},"xhttpSettings":{"path":"/api","mode":"packet-up"}}`
parsed := svc.streamData(stream, "")
mux := globalMux
if xhttp, ok := parsed["xhttpSettings"].(map[string]any); ok {
if _, hasXmux := xhttp["xmux"]; hasXmux {
mux = ""
}
}
streamSettings, _ := json.Marshal(parsed)
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`}
client := model.Client{ID: "uuid-1"}
raw := svc.genVless(&SubService{}, inbound, streamSettings, client, mux)
var ob map[string]any
if err := json.Unmarshal(raw, &ob); err != nil {
t.Fatalf("unmarshal outbound: %v", err)
}
m, has := ob["mux"]
if !has {
t.Fatal("outbound.Mux must be set when global subJsonMux is configured and no per-inbound xmux")
}
mm, _ := m.(map[string]any)
if mm["enabled"] != true || mm["concurrency"] != float64(8) {
t.Fatalf("mux payload wrong: %#v", m)
}
}
func realitySpiderXFromStream(t *testing.T, svc *SubJsonService, clientKey string) string {
t.Helper()
stream := svc.streamData(`{
"network":"tcp","security":"reality","tcpSettings":{"header":{"type":"none"}},
"realitySettings":{
"serverNames":["reality.example.com"],
"shortIds":["ab12cd"],
"settings":{"publicKey":"PBKvalue","fingerprint":"firefox","spiderX":"/seed"}
}
}`, clientKey)
rlty, _ := stream["realitySettings"].(map[string]any)
if rlty == nil {
t.Fatal("streamData dropped realitySettings")
}
spx, _ := rlty["spiderX"].(string)
if len(spx) != 16 || spx[0] != '/' {
t.Fatalf("spiderX = %q, want a 16-char /-prefixed value", spx)
}
return spx
}
func TestSubJsonServiceRealityDataDerivesPerClientSpiderX(t *testing.T) {
svc := NewSubJsonService("", "", "", nil)
alice := realitySpiderXFromStream(t, svc, "subAlice")
if again := realitySpiderXFromStream(t, svc, "subAlice"); again != alice {
t.Fatalf("spiderX not stable for the same client: %q vs %q", alice, again)
}
if bob := realitySpiderXFromStream(t, svc, "subBob"); bob == alice {
t.Fatalf("spiderX identical across clients (fingerprintable): %q", alice)
}
}
// streamData must tolerate malformed stored inbounds: unparseable stream JSON
// (with a finalMask configured, which writes into the map) and tls/reality
// security whose settings key is missing or null previously panicked the
// subscription request.
func TestSubJsonServiceStreamDataMalformedInputs(t *testing.T) {
withMask := NewSubJsonService("", "", `{"tcp":[{"type":"fragment"}]}`, nil)
stream := withMask.streamData("not-json", "clientKey")
if _, ok := stream["finalmask"]; !ok {
t.Fatal("finalMask must still apply when stream settings fail to parse")
}
svc := NewSubJsonService("", "", "", nil)
noReality := svc.streamData(`{"network":"tcp","security":"reality"}`, "clientKey")
if v, ok := noReality["realitySettings"]; ok {
t.Fatalf("missing realitySettings must stay absent, got %v", v)
}
nullTls := svc.streamData(`{"network":"tcp","security":"tls","tlsSettings":null}`, "")
if v, ok := nullTls["tlsSettings"]; ok {
t.Fatalf("null tlsSettings must be dropped, got %v", v)
}
}
func TestSubJsonServiceRealityDataSpiderXFallsBackWhenNoClientKey(t *testing.T) {
svc := NewSubJsonService("", "", "", nil)
stream := svc.streamData(`{
"network":"tcp","security":"reality","tcpSettings":{"header":{"type":"none"}},
"realitySettings":{
"serverNames":["reality.example.com"],
"shortIds":["ab12cd"],
"settings":{"publicKey":"PBKvalue","fingerprint":"firefox"}
}
}`, "")
rlty, _ := stream["realitySettings"].(map[string]any)
if rlty == nil {
t.Fatal("streamData dropped realitySettings")
}
spx, _ := rlty["spiderX"].(string)
if len(spx) != 16 || spx[0] != '/' {
t.Fatalf("spiderX fallback = %q, want random 16-char /-prefixed value", spx)
}
}
func TestSubJsonServiceWireguard(t *testing.T) {
serverPriv, serverPub, err := wgutil.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("server keypair: %v", err)
}
clientPriv, _, err := wgutil.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("client keypair: %v", err)
}
inbound := &model.Inbound{
Listen: "203.0.113.9",
Port: 51820,
Protocol: model.WireGuard,
Settings: `{"secretKey":"` + serverPriv + `","mtu":1420}`,
}
client := model.Client{
Email: "user",
PrivateKey: clientPriv,
PreSharedKey: "psk-value",
KeepAlive: 25,
AllowedIPs: []string{"10.0.0.2/32", "fd00::2/128"},
}
raw := NewSubJsonService("", "", "", nil).genWireguard(inbound, client)
if raw == nil {
t.Fatal("genWireguard returned nil for a valid wireguard client")
}
settings := outboundSettings(t, raw)
if settings["secretKey"] != clientPriv {
t.Fatalf("secretKey = %v, want client private key", settings["secretKey"])
}
address, _ := settings["address"].([]any)
if len(address) != 2 || address[0] != "10.0.0.2/32" || address[1] != "fd00::2/128" {
t.Fatalf("address = %v, want client tunnel addresses", settings["address"])
}
if settings["mtu"] != float64(1420) {
t.Fatalf("mtu = %v, want 1420", settings["mtu"])
}
peers, _ := settings["peers"].([]any)
if len(peers) != 1 {
t.Fatalf("peers len = %d, want 1", len(peers))
}
peer, _ := peers[0].(map[string]any)
if peer["publicKey"] != serverPub {
t.Fatalf("peer publicKey = %v, want %v (derived from inbound secretKey)", peer["publicKey"], serverPub)
}
if peer["endpoint"] != "203.0.113.9:51820" {
t.Fatalf("peer endpoint = %v, want 203.0.113.9:51820", peer["endpoint"])
}
if peer["preSharedKey"] != "psk-value" {
t.Fatalf("peer preSharedKey = %v, want psk-value", peer["preSharedKey"])
}
if peer["keepAlive"] != float64(25) {
t.Fatalf("peer keepAlive = %v, want 25", peer["keepAlive"])
}
allowed, _ := peer["allowedIPs"].([]any)
if !reflect.DeepEqual(allowed, []any{"0.0.0.0/0", "::/0"}) {
t.Fatalf("peer allowedIPs = %v, want full tunnel", peer["allowedIPs"])
}
}
func TestSubJsonServiceWireguardNoKey(t *testing.T) {
inbound := &model.Inbound{Listen: "203.0.113.9", Port: 51820, Protocol: model.WireGuard, Settings: `{}`}
client := model.Client{Email: "user"}
if raw := NewSubJsonService("", "", "", nil).genWireguard(inbound, client); raw != nil {
t.Fatalf("genWireguard = %s, want nil for a keyless wireguard client", raw)
}
}