Browse Source

fix(sub): bind JSON local inbounds to 127.0.0.1 and keep mux.cool off Vision outbounds (#6418)

* fix(sub): bind JSON local inbounds to 127.0.0.1 and keep mux.cool off Vision outbounds

The JSON subscription's local SOCKS/HTTP inbounds had no listen address, so
every client that runs the profile verbatim bound an unauthenticated proxy on
0.0.0.0, and iOS packet-tunnel clients could not reach it at all (Happ iOS:
CONNECTED with zero traffic, same symptom as #6379 — on the same device the
mixed inbound also worked once bound to 127.0.0.1). Bind both to loopback,
which is what every client's own generated config does.

The global subJsonMux was also applied to VLESS outbounds carrying
xtls-rprx-vision. XTLS flows do not support mux.cool: Xray answers the mux
handshake with "common/mux: unexpected network TCP" and the tunnel passes
nothing, on every platform (verified with Happ iOS/Android/macOS, V2Box iOS
and desktop Xray 26.6.27 against a 3x-ui 3.7.0 box with per-client traffic
counters). Skip the mux block whenever the outbound carries a flow.

Refs #6379

* fix(sub): keep XUDP settings when disabling TCP mux on Vision outbounds

Clearing the whole mux object also dropped xudpConcurrency, xudpProxyUDP443
and any per-host muxParams override. Xray reads those only under
mux.enabled, so set concurrency to -1 instead: TCP mux.cool (which XTLS flows
reject) is off, XUDP and the UDP/443 policy stay. The test now decodes each
outbound into a fresh map.

---------

Co-authored-by: Farhan Zare <[email protected]>
Farhan Zare 9 hours ago
parent
commit
0f6e1ae8d7

+ 1 - 1
docs/content/docs/en/config/subscription.mdx

@@ -57,7 +57,7 @@ page instead of the raw body.
 
 The **Base64** body is just the newline-joined share links, standard-base64
 encoded (toggle with `subEncrypt`). The **JSON** body wraps each client in a
-complete Xray client config — a fixed skeleton (local SOCKS/HTTP inbounds, DNS,
+complete Xray client config — a fixed skeleton (local SOCKS/HTTP inbounds bound to 127.0.0.1, DNS,
 routing, policy) plus a `proxy` outbound pointing at the inbound. 3x-ui emits a
 **single config object for one client and an array for several**, uses the flat
 outbound `settings` form (`address`/`port`/`id`, `level: 8`), and strips

+ 1 - 1
docs/content/docs/fa/config/subscription.mdx

@@ -58,7 +58,7 @@ https://<sub-host>:<sub-port>/<sub-path>/<sub-id>
 بدنه‌ی **Base64** صرفاً همان لینک‌های اشتراک‌گذاری است که با خط جدید به هم پیوسته و
 با standard-base64 رمزگذاری شده‌اند (با `subEncrypt` قابل تغییر است). بدنه‌ی **JSON**
 هر کلاینت را در یک پیکربندی کامل کلاینت Xray می‌پیچد — یک اسکلت ثابت (ورودی‌های محلی
-SOCKS/HTTP، DNS، مسیریابی، policy) به‌علاوه‌ی یک outbound از نوع `proxy` که به ورودی
+SOCKS/HTTP روی 127.0.0.1، DNS، مسیریابی، policy) به‌علاوه‌ی یک outbound از نوع `proxy` که به ورودی
 اشاره می‌کند. 3x-ui **برای یک کلاینت یک شیء پیکربندی واحد و برای چند کلاینت یک آرایه**
 تولید می‌کند، از فرم تخت `settings` در outbound استفاده می‌کند
 (`address`/`port`/`id`، `level: 8`) و `sockopt` را از `streamSettings` حذف می‌کند.

+ 1 - 1
docs/content/docs/ru/config/subscription.mdx

@@ -59,7 +59,7 @@ https://<sub-host>:<sub-port>/<sub-path>/<sub-id>
 Тело **Base64** — это просто ссылки для обмена, объединённые через перевод
 строки и закодированные в стандартный base64 (переключается через `subEncrypt`).
 Тело **JSON** оборачивает каждого клиента в полную клиентскую конфигурацию
-Xray — фиксированный каркас (локальные входящие SOCKS/HTTP, DNS, маршрутизация,
+Xray — фиксированный каркас (локальные входящие SOCKS/HTTP на 127.0.0.1, DNS, маршрутизация,
 policy) плюс исходящее соединение `proxy`, указывающее на входящее. 3x-ui
 выдаёт **единый объект конфигурации для одного клиента и массив для
 нескольких**, использует плоскую форму `settings` исходящего соединения

+ 1 - 1
docs/content/docs/zh/config/subscription.mdx

@@ -46,7 +46,7 @@ https://<sub-host>:<sub-port>/<sub-path>/<sub-id>
 
 ### Base64 与 JSON
 
-**Base64** 内容只是用换行符连接的分享链接,经标准 base64 编码(通过 `subEncrypt` 开关控制)。**JSON** 内容则将每个客户端包装为一份完整的 Xray 客户端配置 —— 一套固定的骨架(本地 SOCKS/HTTP 入站、DNS、路由、策略)加上一个指向该入站的 `proxy` 出站。3x-ui **对单个客户端输出单个配置对象,对多个客户端输出数组**,使用扁平的出站 `settings` 形式(`address`/`port`/`id`,`level: 8`),并从 `streamSettings` 中剥离 `sockopt`。
+**Base64** 内容只是用换行符连接的分享链接,经标准 base64 编码(通过 `subEncrypt` 开关控制)。**JSON** 内容则将每个客户端包装为一份完整的 Xray 客户端配置 —— 一套固定的骨架(绑定到 127.0.0.1 的本地 SOCKS/HTTP 入站、DNS、路由、策略)加上一个指向该入站的 `proxy` 出站。3x-ui **对单个客户端输出单个配置对象,对多个客户端输出数组**,使用扁平的出站 `settings` 形式(`address`/`port`/`id`,`level: 8`),并从 `streamSettings` 中剥离 `sockopt`。
 
 ## 响应头
 

+ 2 - 1
docs/lib/xray/subscription.test.ts

@@ -126,11 +126,12 @@ describe('buildJsonSubscription', () => {
     const http = cfg.inbounds.find((inbound: { port: number }) => inbound.port === 10809);
 
     expect(socks).toMatchObject({
+      listen: '127.0.0.1',
       protocol: 'socks',
       tag: 'mixed',
       settings: { udp: true },
     });
-    expect(http).toMatchObject({ protocol: 'http' });
+    expect(http).toMatchObject({ listen: '127.0.0.1', protocol: 'http' });
   });
 
   it('trojan uses servers[] with a password and no method', () => {

+ 8 - 1
docs/lib/xray/subscription.ts

@@ -145,13 +145,20 @@ function subJsonSkeleton(): Record<string, unknown> {
     },
     inbounds: [
       {
+        listen: '127.0.0.1',
         port: 10808,
         protocol: 'socks',
         settings: { auth: 'noauth', udp: true, userLevel: 8 },
         sniffing: { destOverride: ['http', 'tls', 'quic', 'fakedns'], enabled: true },
         tag: 'mixed',
       },
-      { port: 10809, protocol: 'http', settings: { userLevel: 8 }, tag: 'http' },
+      {
+        listen: '127.0.0.1',
+        port: 10809,
+        protocol: 'http',
+        settings: { userLevel: 8 },
+        tag: 'http',
+      },
     ],
     log: { loglevel: 'warning' },
     policy: {

+ 2 - 0
internal/sub/default.json

@@ -12,6 +12,7 @@
   },
   "inbounds": [
     {
+      "listen": "127.0.0.1",
       "port": 10808,
       "protocol": "socks",
       "settings": {
@@ -31,6 +32,7 @@
       "tag": "mixed"
     },
     {
+      "listen": "127.0.0.1",
       "port": 10809,
       "protocol": "http",
       "settings": {

+ 19 - 0
internal/sub/json_service.go

@@ -788,12 +788,31 @@ func (s *SubJsonService) genVless(subReq *SubService, inbound *model.Inbound, st
 	}
 	if client.Flow != "" && !inbound.DisableFlow {
 		settings["flow"] = client.Flow
+		outbound.Mux = muxWithoutTCP(mux)
 	}
 	outbound.Settings = settings
 	result, _ := json.MarshalIndent(outbound, "", "  ")
 	return result
 }
 
+// XTLS flows reject TCP mux.cool ("unexpected network TCP"); concurrency -1
+// turns only that off and keeps the XUDP keys (Xray reads them under enabled).
+func muxWithoutTCP(mux string) json_util.RawMessage {
+	if mux == "" {
+		return nil
+	}
+	var m map[string]any
+	if err := json.Unmarshal([]byte(mux), &m); err != nil || m == nil {
+		return nil
+	}
+	m["concurrency"] = -1
+	out, err := json.Marshal(m)
+	if err != nil {
+		return nil
+	}
+	return json_util.RawMessage(out)
+}
+
 func (s *SubJsonService) genServer(subReq *SubService, inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, mux string) json_util.RawMessage {
 	outbound := Outbound{}
 

+ 44 - 0
internal/sub/json_service_test.go

@@ -66,11 +66,55 @@ func TestDefaultJSONUsesCompatibleLocalInbounds(t *testing.T) {
 	if settings == nil || settings["udp"] != true {
 		t.Fatalf("port 10808 settings = %#v, want udp enabled", socks["settings"])
 	}
+	if socks["listen"] != "127.0.0.1" {
+		t.Fatalf("port 10808 listen = %#v, want 127.0.0.1 (an unbound local inbound is exposed to the LAN and is not reachable by iOS packet tunnels)", socks["listen"])
+	}
 
 	http := byPort[10809]
 	if http == nil || http["protocol"] != "http" {
 		t.Fatalf("port 10809 inbound = %#v, want http protocol", http)
 	}
+	if http["listen"] != "127.0.0.1" {
+		t.Fatalf("port 10809 listen = %#v, want 127.0.0.1", http["listen"])
+	}
+}
+
+func TestSubJsonServiceVisionFlowDisablesTCPMuxOnly(t *testing.T) {
+	globalMux := `{"enabled":true,"concurrency":8,"xudpConcurrency":16,"xudpProxyUDP443":"reject"}`
+	svc := NewSubJsonService(globalMux, "", "", nil)
+	inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`}
+
+	decode := func(raw []byte) map[string]any {
+		t.Helper()
+		var ob map[string]any
+		if err := json.Unmarshal(raw, &ob); err != nil {
+			t.Fatalf("unmarshal outbound: %v", err)
+		}
+		return ob
+	}
+
+	vision := decode([]byte(svc.genVless(&SubService{}, inbound, nil, model.Client{ID: "uuid-1", Flow: "xtls-rprx-vision"}, globalMux)))
+	mux, _ := vision["mux"].(map[string]any)
+	if mux == nil {
+		t.Fatalf("vision outbound must keep its mux object for the XUDP keys, got %#v", vision["mux"])
+	}
+	if mux["concurrency"] != float64(-1) {
+		t.Fatalf("vision outbound mux.concurrency = %v, want -1 (TCP mux.cool is rejected by XTLS flows)", mux["concurrency"])
+	}
+	if mux["enabled"] != true || mux["xudpConcurrency"] != float64(16) || mux["xudpProxyUDP443"] != "reject" {
+		t.Fatalf("vision outbound lost its XUDP settings: %#v", mux)
+	}
+
+	plain := decode([]byte(svc.genVless(&SubService{}, inbound, nil, model.Client{ID: "uuid-1"}, globalMux)))
+	mux, _ = plain["mux"].(map[string]any)
+	if mux == nil || mux["concurrency"] != float64(8) {
+		t.Fatalf("flow-less outbound must keep the global mux unchanged, got %#v", plain["mux"])
+	}
+
+	noMux := decode([]byte(svc.genVless(&SubService{}, inbound, nil, model.Client{ID: "uuid-1", Flow: "xtls-rprx-vision"}, "")))
+	if _, has := noMux["mux"]; has {
+		t.Fatalf("no global mux must still mean no mux key, got %#v", noMux["mux"])
+	}
 }
 
 func TestSubJsonServiceInjectsGlobalFinalMask(t *testing.T) {