From 0f6e1ae8d73bf6ae2d29aa346a76607fcbe1c53e Mon Sep 17 00:00:00 2001 From: Farhan Zare Date: Sat, 5 Sep 2026 14:44:48 -0400 Subject: [PATCH] fix(sub): bind JSON local inbounds to 127.0.0.1 and keep mux.cool off Vision outbounds (#6418) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- docs/content/docs/en/config/subscription.mdx | 2 +- docs/content/docs/fa/config/subscription.mdx | 2 +- docs/content/docs/ru/config/subscription.mdx | 2 +- docs/content/docs/zh/config/subscription.mdx | 2 +- docs/lib/xray/subscription.test.ts | 3 +- docs/lib/xray/subscription.ts | 9 +++- internal/sub/default.json | 2 + internal/sub/json_service.go | 19 +++++++++ internal/sub/json_service_test.go | 44 ++++++++++++++++++++ 9 files changed, 79 insertions(+), 6 deletions(-) diff --git a/docs/content/docs/en/config/subscription.mdx b/docs/content/docs/en/config/subscription.mdx index 77679d95f..2be1c3f03 100644 --- a/docs/content/docs/en/config/subscription.mdx +++ b/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 diff --git a/docs/content/docs/fa/config/subscription.mdx b/docs/content/docs/fa/config/subscription.mdx index 2269c5d91..3bc494169 100644 --- a/docs/content/docs/fa/config/subscription.mdx +++ b/docs/content/docs/fa/config/subscription.mdx @@ -58,7 +58,7 @@ https://:// بدنه‌ی **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` حذف می‌کند. diff --git a/docs/content/docs/ru/config/subscription.mdx b/docs/content/docs/ru/config/subscription.mdx index 9c05dd33d..025151014 100644 --- a/docs/content/docs/ru/config/subscription.mdx +++ b/docs/content/docs/ru/config/subscription.mdx @@ -59,7 +59,7 @@ https://:// Тело **Base64** — это просто ссылки для обмена, объединённые через перевод строки и закодированные в стандартный base64 (переключается через `subEncrypt`). Тело **JSON** оборачивает каждого клиента в полную клиентскую конфигурацию -Xray — фиксированный каркас (локальные входящие SOCKS/HTTP, DNS, маршрутизация, +Xray — фиксированный каркас (локальные входящие SOCKS/HTTP на 127.0.0.1, DNS, маршрутизация, policy) плюс исходящее соединение `proxy`, указывающее на входящее. 3x-ui выдаёт **единый объект конфигурации для одного клиента и массив для нескольких**, использует плоскую форму `settings` исходящего соединения diff --git a/docs/content/docs/zh/config/subscription.mdx b/docs/content/docs/zh/config/subscription.mdx index 380a77bb1..b5bbf811d 100644 --- a/docs/content/docs/zh/config/subscription.mdx +++ b/docs/content/docs/zh/config/subscription.mdx @@ -46,7 +46,7 @@ https://:// ### 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`。 ## 响应头 diff --git a/docs/lib/xray/subscription.test.ts b/docs/lib/xray/subscription.test.ts index d0d0c128b..a79f78705 100644 --- a/docs/lib/xray/subscription.test.ts +++ b/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', () => { diff --git a/docs/lib/xray/subscription.ts b/docs/lib/xray/subscription.ts index 0237dd935..c06337a78 100644 --- a/docs/lib/xray/subscription.ts +++ b/docs/lib/xray/subscription.ts @@ -145,13 +145,20 @@ function subJsonSkeleton(): Record { }, 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: { diff --git a/internal/sub/default.json b/internal/sub/default.json index f16de25b0..1203eb218 100644 --- a/internal/sub/default.json +++ b/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": { diff --git a/internal/sub/json_service.go b/internal/sub/json_service.go index e2d866aaa..48ec3a133 100644 --- a/internal/sub/json_service.go +++ b/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{} diff --git a/internal/sub/json_service_test.go b/internal/sub/json_service_test.go index 1e142750c..58e431707 100644 --- a/internal/sub/json_service_test.go +++ b/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) {