From 61b129d5b396dfeb56f738f5b9320a6f23bf8163 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Thu, 22 Oct 2026 17:10:11 +0200 Subject: [PATCH] feat(xray): update xray-core to v26.10.21 and adapt panel Bump xtls/xray-core to 1b1647cd9ddf (v26.10.21) and move the three binary pins (DockerInit.sh, both release.yml fetches) in lockstep. The v26.10.10 release was pulled upstream, so the old pins already 404. XDRIVE (#7182): the core now runs XDRIVE only for VLESS inside VLESS encryption, with no flow, and an outbound only over Mux; it refuses the whole config otherwise. The panel offers XDRIVE only for VLESS, refuses to save an inbound without decryption, never emits a client flow on an XDRIVE stream (config, JSON subscription, forms), and turns Mux on when an outbound picks XDRIVE. An inbound or template outbound stored before the upgrade that the core would refuse is left out of the generated config with a warning, so it no longer stops every other inbound. The service "Google Drive" is now "google-drive" (old name read as a deprecated alias); saves, the schema and both form adapters store the new name. XDRIVE and MASQUE gain a client xmux; both schemas keep it instead of stripping it on an inbound save. Lua scripts (#7124, #7136): the core refuses a routing script beside rules or domainStrategy, and a dns script beside the fallback/parallel switches or a domain-scoped server. The panel always keeps its api routing rule, so a template save now refuses any routing script and a dns script in a refused combination, and a stored one is dropped from the generated config so the core still starts. Noise masks gain a Lua script that the loader compiles inside Build: the in-process build (template save check, hot add) now resolves a relative script the way the xray child does, beside the xray binary, instead of failing it. The mask editor gets a Script field that replaces the noise list. XDNS (#7140): a resolver takes https:// DoH addresses with worker, tlsConfig and quicConfig. The mask editor registered only addrs, so a save dropped the rest; it now edits all four. Finalmask filter (#7206): a new TCP/UDP mask screens an inbound's source IPs. The inbound mask editor offers it with its allow and block lists; it is a no-op on a dialer and an older client core refuses the type, so the share link and JSON subscription drop it like the Go link already does. --- .github/workflows/release.yml | 4 +- DockerInit.sh | 2 +- docs/content/docs/en/config/transports.mdx | 17 +- docs/content/docs/fa/config/transports.mdx | 17 +- docs/content/docs/ru/config/transports.mdx | 18 +- docs/content/docs/zh/config/transports.mdx | 13 +- .../xray/forms/transport/FinalMaskForm.tsx | 172 +++++++++++++++--- .../lib/xray/forms/transport/XDriveForm.tsx | 5 +- frontend/src/lib/xray/inbound-form-adapter.ts | 2 + frontend/src/lib/xray/inbound-link.ts | 19 +- .../src/lib/xray/outbound-form-adapter.ts | 2 + .../src/lib/xray/protocol-capabilities.ts | 3 + .../pages/inbounds/form/InboundFormModal.tsx | 2 +- .../xray/outbounds/OutboundFormModal.tsx | 6 +- .../xray/outbounds/outbound-form-constants.ts | 7 + .../src/schemas/protocols/stream/finalmask.ts | 5 +- .../src/schemas/protocols/stream/masque.ts | 3 + .../src/schemas/protocols/stream/xdrive.ts | 22 ++- .../test/__snapshots__/stream.test.ts.snap | 2 +- frontend/src/test/finalmask-form.test.tsx | 18 ++ .../fixtures/stream/xdrive-google-drive.json | 2 +- frontend/src/test/inbound-form-modal.test.tsx | 50 ++++- frontend/src/test/inbound-from-db.test.ts | 12 +- frontend/src/test/inbound-link.test.ts | 16 ++ .../src/test/outbound-form-modal.test.tsx | 163 ++++++++++++++++- frontend/src/test/stream.test.ts | 26 +++ go.mod | 9 +- go.sum | 18 +- internal/sub/json_service.go | 1 + internal/sub/json_service_test.go | 21 +++ internal/sub/service.go | 27 ++- internal/sub/service_flow_test.go | 1 + .../web/service/golden_fixtures_xray_test.go | 9 +- internal/web/service/inbound.go | 57 +++++- internal/web/service/inbound_protocol.go | 9 +- internal/web/service/inbound_protocol_test.go | 5 +- internal/web/service/inbound_xdrive_test.go | 110 ++++++++++- internal/web/service/xray.go | 101 ++++++++++ .../web/service/xray_config_clients_test.go | 20 +- internal/web/service/xray_setting.go | 22 +++ internal/web/service/xray_setting_test.go | 59 ++++++ internal/xray/api.go | 52 ++++++ internal/xray/outbound_validation_test.go | 21 +++ 43 files changed, 1055 insertions(+), 95 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7bae6d4e3..6230b2eca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -115,7 +115,7 @@ jobs: cd x-ui/bin # Download dependencies - Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.10.10/" + Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.10.21/" if [ "${{ matrix.platform }}" == "amd64" ]; then fetch ${Xray_URL}Xray-linux-64.zip unzip Xray-linux-64.zip @@ -283,7 +283,7 @@ jobs: cd x-ui\bin # Download Xray for Windows - $Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.10.10/" + $Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.10.21/" Invoke-WebRequest @retry -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip" Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath . Remove-Item "Xray-windows-64.zip" diff --git a/DockerInit.sh b/DockerInit.sh index 18fccad21..8f8da48f9 100755 --- a/DockerInit.sh +++ b/DockerInit.sh @@ -33,7 +33,7 @@ if [ -z "$MTG_MULTI_VER" ]; then fi mkdir -p build/bin cd build/bin -curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.10.10/Xray-linux-${ARCH}.zip" +curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.10.21/Xray-linux-${ARCH}.zip" unzip "Xray-linux-${ARCH}.zip" rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat mv xray "xray-linux-${FNAME}" diff --git a/docs/content/docs/en/config/transports.mdx b/docs/content/docs/en/config/transports.mdx index 20cf6f981..399bb1044 100644 --- a/docs/content/docs/en/config/transports.mdx +++ b/docs/content/docs/en/config/transports.mdx @@ -125,9 +125,14 @@ that folder instead of accepting connections on its port. Both sides must use th **same** service, folder and secrets, so the JSON subscription ships them to every client; XDRIVE inbounds get no share link or Clash entry. +Since Xray 26.10.17 XDRIVE carries only **VLESS** inside **VLESS encryption**, with +no flow: an inbound needs a `decryption` value, an outbound its `encryption` and Mux +enabled. The panel offers XDRIVE only for VLESS, refuses to save an inbound without +`decryption`, and leaves one stored before the upgrade out of the running config. + | Field | Default | Meaning | | -------------- | -------------- | ----------------------------------------------------------------------- | -| `service` | `Google Drive` | `Google Drive`, `local` (a folder on disk) or `template` (any HTTP storage API). | +| `service` | `google-drive` | `google-drive`, `local` (a folder on disk) or `template` (any HTTP storage API). The old `Google Drive` spelling is still read. | | `remoteFolder` | — | Folder that holds the session files. | | `secrets` | — | Google Drive: ClientID, ClientSecret, RefreshToken — exactly three, in that order. Template: values read as `{secret0}`, `{secret1}`, …. | | `template` | — | Template service only: the storage API's `auth`, `put`, `get`, `list` and `delete` operations. | @@ -147,9 +152,10 @@ TLS. Masks are configured per direction: - **TCP masks** — `fragment`, `sudoku`, `header-custom`, `xmc` (disguises the stream as Minecraft protocol traffic; requires a password, with optional - hostname and player usernames). + hostname and player usernames), plus the inbound-only `filter`. - **UDP masks** — `salamander`, `mkcp-legacy`, `header-custom`, `xdns`, `xicmp`, - `noise`, `sudoku`, `realm`, plus the client-only `udphop`. (`mkcp-legacy` + `noise`, `sudoku`, `realm`, plus the client-only `udphop` and the inbound-only + `filter`. (`mkcp-legacy` reproduces the old mKCP header obfuscation; `udphop` rotates the remote port — and optionally the remote IP — on an interval or per connection to dodge port blocking.) @@ -160,6 +166,11 @@ TLS. Masks are configured per direction: Xray 26.9.9 a client hops only through the `udphop` mask, so the panel converts an older client `quicParams.udpHop` to it. +`filter` (Xray 26.10.21) drops an inbound's connections by source IP: +`allowedSourceIPs` admits only the listed sources, `blockedSourceIPs` drops its +sources, and a blocked match wins. Both take CIDRs or `geoip:` entries. Share links +and JSON subscriptions leave it out, since it only acts on the listener. + FinalMask replaces the per-transport `header`/`seed` obfuscation that older Xray builds exposed. diff --git a/docs/content/docs/fa/config/transports.mdx b/docs/content/docs/fa/config/transports.mdx index faf5b316d..8d74b7681 100644 --- a/docs/content/docs/fa/config/transports.mdx +++ b/docs/content/docs/fa/config/transports.mdx @@ -126,9 +126,14 @@ XDRIVE جریان را به‌صورت فایل‌هایی در پوشه‌ای و مقادیر محرمانهٔ **یکسان** داشته باشند، بنابراین اشتراک JSON آن‌ها را به همهٔ کلاینت‌ها می‌فرستد؛ inboundهای XDRIVE لینک اشتراک‌گذاری یا ورودی Clash ندارند. +از Xray 26.10.17، XDRIVE فقط **VLESS** را درون **رمزنگاری VLESS** و بدون flow حمل می‌کند: +inbound به مقدار `decryption` نیاز دارد و outbound به `encryption` و Mux فعال. پنل XDRIVE +را فقط برای VLESS ارائه می‌کند، ذخیرهٔ inbound بدون `decryption` را رد می‌کند و inboundی را +که پیش از به‌روزرسانی ذخیره شده بیرون از پیکربندی در حال اجرا نگه می‌دارد. + | فیلد | پیش‌فرض | معنا | | -------------- | -------------- | ---- | -| `service` | `Google Drive` | `Google Drive`، `local` (پوشه‌ای روی دیسک) یا `template` (هر API ذخیره‌سازی HTTP). | +| `service` | `google-drive` | `google-drive`، `local` (پوشه‌ای روی دیسک) یا `template` (هر API ذخیره‌سازی HTTP). نگارش قدیمی `Google Drive` همچنان خوانده می‌شود. | | `remoteFolder` | — | پوشه‌ای که فایل‌های نشست در آن قرار می‌گیرند. | | `secrets` | — | Google Drive: دقیقاً سه مقدار ClientID، ClientSecret و RefreshToken به همین ترتیب. Template: مقادیری که با `{secret0}`، `{secret1}`، … خوانده می‌شوند. | | `template` | — | فقط برای سرویس template: عملیات `auth`، `put`، `get`، `list` و `delete` در API ذخیره‌ساز. | @@ -148,9 +153,10 @@ ALPN آن را می‌بیند. - **ماسک‌های TCP** — `fragment`، `sudoku`، `header-custom`، `xmc` (ترافیک را به شکل پروتکل Minecraft استتار می‌کند؛ گذرواژه الزامی است و نام میزبان و نام‌های بازیکن - اختیاری‌اند). + اختیاری‌اند) و `filter` که فقط برای inbound است. - **ماسک‌های UDP** — `salamander`، `mkcp-legacy`، `header-custom`، `xdns`، `xicmp`، - `noise`، `sudoku`، `realm` و `udphop` که فقط سمت کلاینت است. (`mkcp-legacy` + `noise`، `sudoku`، `realm`، `udphop` که فقط سمت کلاینت است و `filter` که فقط برای + inbound است. (`mkcp-legacy` همان مبهم‌سازی قدیمی هدر mKCP را بازتولید می‌کند؛ `udphop` پورت مقصد — و در صورت نیاز IP مقصد — را در فواصل زمانی یا به‌ازای هر اتصال عوض می‌کند تا مسدودسازی پورت دور زده شود.) @@ -161,6 +167,11 @@ ALPN آن را می‌بیند. 26.9.9 کلاینت فقط با ماسک `udphop` پورت عوض می‌کند، برای همین پنل `quicParams.udpHop` قدیمی کلاینت را به آن تبدیل می‌کند. +‏`filter` (Xray 26.10.21) اتصال‌های یک inbound را بر اساس IP مبدأ رد می‌کند: +`allowedSourceIPs` فقط مبدأهای فهرست‌شده را می‌پذیرد، `blockedSourceIPs` مبدأهایش را رد +می‌کند و تطابق با فهرست مسدود اولویت دارد. هر دو CIDR یا مدخل `geoip:` می‌پذیرند. لینک‌های +اشتراک و اشتراک JSON آن را حذف می‌کنند، چون فقط روی شنونده اثر دارد. + ‏FinalMask جایگزین مبهم‌سازی `header`/`seed` به‌ازای هر انتقال می‌شود که بیلدهای قدیمی‌تر Xray نمایش می‌دادند. diff --git a/docs/content/docs/ru/config/transports.mdx b/docs/content/docs/ru/config/transports.mdx index 8b095a597..ee44e7df3 100644 --- a/docs/content/docs/ru/config/transports.mdx +++ b/docs/content/docs/ru/config/transports.mdx @@ -128,9 +128,14 @@ XDRIVE передаёт поток файлами в папке, доступн **одинаковые** сервис, папку и секреты, поэтому JSON-подписка передаёт их каждому клиенту; для XDRIVE-инбаундов не создаются ссылки и записи Clash. +Начиная с Xray 26.10.17 XDRIVE передаёт только **VLESS** внутри **шифрования VLESS** и +без flow: инбаунду нужно значение `decryption`, исходящему — `encryption` и включённый +Mux. Панель предлагает XDRIVE только для VLESS, не сохраняет инбаунд без `decryption` и +не включает в работающую конфигурацию инбаунд, сохранённый до обновления. + | Поле | По умолчанию | Значение | | -------------- | -------------- | -------- | -| `service` | `Google Drive` | `Google Drive`, `local` (папка на диске) или `template` (любой HTTP API хранилища). | +| `service` | `google-drive` | `google-drive`, `local` (папка на диске) или `template` (любой HTTP API хранилища). Старое написание `Google Drive` по-прежнему читается. | | `remoteFolder` | — | Папка с файлами сессий. | | `secrets` | — | Google Drive: ровно три значения — ClientID, ClientSecret, RefreshToken, в этом порядке. Template: значения, доступные как `{secret0}`, `{secret1}`, …. | | `template` | — | Только для template: операции `auth`, `put`, `get`, `list` и `delete` API хранилища. | @@ -150,9 +155,10 @@ SNI, отпечаток uTLS и ALPN. - **TCP-маски** — `fragment`, `sudoku`, `header-custom`, `xmc` (маскирует поток под трафик протокола Minecraft; требуется пароль, имя хоста и имена игроков - опциональны). + опциональны), а также только инбаундная `filter`. - **UDP-маски** — `salamander`, `mkcp-legacy`, `header-custom`, `xdns`, `xicmp`, - `noise`, `sudoku`, `realm`, а также только клиентская `udphop`. (`mkcp-legacy` + `noise`, `sudoku`, `realm`, а также только клиентская `udphop` и только инбаундная + `filter`. (`mkcp-legacy` воспроизводит старую обфускацию заголовка mKCP; `udphop` меняет удалённый порт — и при желании удалённый IP — по интервалу или для каждого соединения, чтобы обходить блокировку портов.) @@ -163,6 +169,12 @@ SNI, отпечаток uTLS и ALPN. порт. Начиная с Xray 26.9.9 клиент меняет порт только через маску `udphop`, поэтому панель преобразует старый клиентский `quicParams.udpHop` в неё. +`filter` (Xray 26.10.21) отбрасывает соединения инбаунда по IP источника: +`allowedSourceIPs` пропускает только перечисленные источники, `blockedSourceIPs` +отбрасывает свои, а совпадение с блок-листом имеет приоритет. Оба принимают CIDR или +записи `geoip:`. Ссылки и JSON-подписки её не содержат, ведь она действует только на +слушающей стороне. + FinalMask заменяет обфускацию `header`/`seed` на уровне отдельного транспорта, которую предоставляли старые сборки Xray. diff --git a/docs/content/docs/zh/config/transports.mdx b/docs/content/docs/zh/config/transports.mdx index eb2d98097..e6e6562a2 100644 --- a/docs/content/docs/zh/config/transports.mdx +++ b/docs/content/docs/zh/config/transports.mdx @@ -119,9 +119,12 @@ Session-ID 字段(`sessionIDPlacement`、`sessionIDKey`、`sessionIDTable`、 XDRIVE 把数据流作为文件存放在双方都能访问的文件夹中:服务端轮询该文件夹,而不是在端口上接受连接。 双方必须使用**相同**的服务、文件夹和密钥,因此 JSON 订阅会把它们下发给每个客户端;XDRIVE 入站不生成分享链接或 Clash 条目。 +自 Xray 26.10.17 起,XDRIVE 只在 **VLESS 加密**内承载 **VLESS**,且不支持 flow:入站需要设置 `decryption`, +出站需要 `encryption` 并启用 Mux。面板只为 VLESS 提供 XDRIVE,拒绝保存没有 `decryption` 的入站,并把升级前保存的此类入站排除在运行配置之外。 + | 字段 | 默认值 | 含义 | | -------------- | -------------- | ---- | -| `service` | `Google Drive` | `Google Drive`、`local`(磁盘上的文件夹)或 `template`(任意 HTTP 存储 API)。 | +| `service` | `google-drive` | `google-drive`、`local`(磁盘上的文件夹)或 `template`(任意 HTTP 存储 API)。旧写法 `Google Drive` 仍可读取。 | | `remoteFolder` | — | 存放会话文件的文件夹。 | | `secrets` | — | Google Drive:恰好三项,依次为 ClientID、ClientSecret、RefreshToken。Template:以 `{secret0}`、`{secret1}`… 引用的值。 | | `template` | — | 仅用于 template 服务:存储 API 的 `auth`、`put`、`get`、`list` 和 `delete` 操作。 | @@ -137,9 +140,9 @@ XDRIVE 没有自己的 TLS/REALITY 层——其流量就是存储 API 自身的 传输(如 mKCP),也能在 TLS 之上再加一层外壳。掩码按方向分别配置: - **TCP 掩码** — `fragment`、`sudoku`、`header-custom`、`xmc`(把流量伪装成 - Minecraft 协议;密码必填,主机名和玩家用户名可选)。 + Minecraft 协议;密码必填,主机名和玩家用户名可选),以及仅限入站的 `filter`。 - **UDP 掩码** — `salamander`、`mkcp-legacy`、`header-custom`、`xdns`、`xicmp`、 - `noise`、`sudoku`、`realm`,以及仅限客户端的 `udphop`。(`mkcp-legacy` 重现旧版 + `noise`、`sudoku`、`realm`,以及仅限客户端的 `udphop` 和仅限入站的 `filter`。(`mkcp-legacy` 重现旧版 mKCP 的头部混淆;`udphop` 按时间间隔或按连接轮换远端端口——也可轮换远端 IP——以规避 端口封锁。) - **QUIC 参数** — 拥塞控制(`reno`、`bbr`、`brutal`、`force-brutal`)、Brutal 上/下行 @@ -148,6 +151,10 @@ XDRIVE 没有自己的 TLS/REALITY 层——其流量就是存储 API 自身的 26.9.9 起客户端只通过 `udphop` 掩码跳端口,因此面板会把客户端旧的 `quicParams.udpHop` 转换为该掩码。 +`filter`(Xray 26.10.21)按源 IP 丢弃入站连接:`allowedSourceIPs` 只放行所列来源, +`blockedSourceIPs` 丢弃所列来源,命中封锁列表优先。两者都接受 CIDR 或 `geoip:` 条目。 +分享链接和 JSON 订阅会去掉它,因为它只作用于监听端。 + FinalMask 取代了旧版 Xray 构建中暴露的、按每种传输各自实现的 `header`/`seed` 混淆。 ## sockopt — 底层套接字选项 diff --git a/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx b/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx index 3652dfa7d..cdfd174a5 100644 --- a/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx +++ b/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx @@ -36,8 +36,8 @@ export interface FinalMaskFormProps { // network/protocol. Used by the global sub-JSON finalmask editor where // the masks apply to every stream rather than one specific transport. showAll?: boolean; - // udphop runs only on the dialing side (the core refuses it on a listener), so a server - // keeps the advertised hop range in quicParams.udpHop and a client gets the mask. + // udphop runs only on the dialing side (the core refuses it on a listener), so a server keeps + // the advertised hop range in quicParams.udpHop; filter is the listener-only counterpart. side?: 'server' | 'client'; } @@ -332,7 +332,7 @@ export default function FinalMaskForm({ return ( <> - {showTcp && } + {showTcp && } {showUdp && ( )} {showQuic && ( @@ -366,7 +367,15 @@ export default function FinalMaskForm({ ); } -function TcpMasksList({ base, form }: { base: (string | number)[]; form: FormInstance }) { +function TcpMasksList({ + base, + form, + allowFilter, +}: { + base: (string | number)[]; + form: FormInstance; + allowFilter: boolean; +}) { const { t } = useTranslation(); return ( @@ -390,6 +399,7 @@ function TcpMasksList({ base, form }: { base: (string | number)[]; form: FormIns displayIndex={mIdx + 1} form={form} listPath={[...base, 'tcp']} + allowFilter={allowFilter} onRemove={() => remove(field.name)} /> ))} @@ -404,12 +414,14 @@ function TcpMaskItem({ displayIndex, form, listPath, + allowFilter, onRemove, }: { fieldName: number; displayIndex: number; form: FormInstance; listPath: (string | number)[]; + allowFilter: boolean; onRemove: () => void; }) { // Absolute path for setFieldValue side effects (resetting settings on @@ -442,6 +454,7 @@ function TcpMaskItem({ { value: 'header-custom', label: 'Header Custom' }, { value: 'sudoku', label: 'Sudoku' }, { value: 'xmc', label: 'XMC (Minecraft)' }, + ...(allowFilter ? [FILTER_MASK_OPTION] : []), ]} /> @@ -493,6 +506,7 @@ function TcpMaskItem({ ); } if (type === 'sudoku') return ; + if (type === 'filter') return ; if (type === 'header-custom') { return ( + + + + + ); +} + function SudokuSettings({ fieldName }: { fieldName: number }) { return ( <> @@ -882,6 +931,7 @@ function UdpMasksList({ isWireguard, network, allowUdpHop, + allowFilter, }: { base: (string | number)[]; form: FormInstance; @@ -889,6 +939,7 @@ function UdpMasksList({ isWireguard: boolean; network: string; allowUdpHop: boolean; + allowFilter: boolean; }) { const { t } = useTranslation(); return ( @@ -918,6 +969,7 @@ function UdpMasksList({ isWireguard={isWireguard} network={network} allowUdpHop={allowUdpHop} + allowFilter={allowFilter} onRemove={() => remove(field.name)} /> ))} @@ -936,6 +988,7 @@ function UdpMaskItem({ isWireguard, network, allowUdpHop, + allowFilter, onRemove, }: { fieldName: number; @@ -946,6 +999,7 @@ function UdpMaskItem({ isWireguard: boolean; network: string; allowUdpHop: boolean; + allowFilter: boolean; onRemove: () => void; }) { const absolutePath = [...listPath, fieldName]; @@ -960,8 +1014,9 @@ function UdpMaskItem({ }; const udpHopOption = allowUdpHop ? [{ value: 'udphop', label: 'UDP Hop' }] : []; + const filterOption = allowFilter ? [FILTER_MASK_OPTION] : []; const options = isHysteria - ? [{ value: 'salamander', label: 'Salamander (Hysteria2)' }, ...udpHopOption] + ? [{ value: 'salamander', label: 'Salamander (Hysteria2)' }, ...udpHopOption, ...filterOption] : [ // Salamander is the mask xray-core's own wireguard finalmask example // uses; it stays hysteria-only elsewhere to keep legacy parity. @@ -974,6 +1029,7 @@ function UdpMaskItem({ { value: 'noise', label: 'Noise' }, { value: 'sudoku', label: 'Sudoku' }, ...udpHopOption, + ...filterOption, ]; return ( @@ -1039,6 +1095,7 @@ function UdpMaskItem({ if (type === 'udphop') { return ; } + if (type === 'filter') return ; if (type === 'xicmp') { return ( <> @@ -1430,23 +1487,44 @@ function XdnsSettings({ udpFieldName }: { udpFieldName: number }) { /> {resolvers.map((resolver, ri) => ( - - - - + + + + + + + + + + + ))} )} @@ -1531,6 +1609,7 @@ function validateNoiseReset(_: unknown, value: unknown) { return valid ? Promise.resolve() : Promise.reject(new Error('seconds, e.g. 0 or 60-120')); } +// xray-core 26.10.17 (#7124) refuses a noise script beside noise items, so a script replaces the list. function NoiseItems({ udpFieldName, form, @@ -1539,18 +1618,49 @@ function NoiseItems({ udpFieldName: number; form: FormInstance; absoluteSettingsPath: (string | number)[]; +}) { + const script = Form.useWatch([...absoluteSettingsPath, 'script'], form) as string | undefined; + return ( + <> + + + + value || undefined} + > + + + {!script && ( + + )} + + ); +} + +function NoiseItemList({ + udpFieldName, + form, + absoluteSettingsPath, +}: { + udpFieldName: number; + form: FormInstance; + absoluteSettingsPath: (string | number)[]; }) { const { t } = useTranslation(); return ( <> - - - {(items, { add, remove }) => ( <> @@ -1617,12 +1727,14 @@ function validateCustomVarName(_: unknown, value: unknown) { } // Hands the form only a parsed JSON object, so half-typed text never reaches the config. -function TransformEditor({ +function JsonObjectInput({ value, onChange, + placeholder, }: { value?: Record; onChange?: (next: Record | undefined) => void; + placeholder?: string; }) { const [text, setText] = useState(() => (value ? JSON.stringify(value) : '')); const [invalid, setInvalid] = useState(false); @@ -1631,7 +1743,7 @@ function TransformEditor({ { const next = e.target.value; setText(next); @@ -1755,7 +1867,7 @@ function ItemEditor({ tooltip='An expression tree, e.g. {"op": "concat", "args": [{"type": "str", "bytes": "OK "}, {"reuse": "nonce"}]}' rules={[{ required: true, message: 'enter the transform expression' }]} > - + )} diff --git a/frontend/src/lib/xray/forms/transport/XDriveForm.tsx b/frontend/src/lib/xray/forms/transport/XDriveForm.tsx index 3b1402f86..20b84cdab 100644 --- a/frontend/src/lib/xray/forms/transport/XDriveForm.tsx +++ b/frontend/src/lib/xray/forms/transport/XDriveForm.tsx @@ -13,6 +13,7 @@ const SERVICE_LABEL_KEYS: Partial> = { local: 'pages.inbounds.form.xdrive.serviceLocal', template: 'pages.inbounds.form.xdrive.serviceTemplate', }; +const SERVICE_NAMES: Partial> = { 'google-drive': 'Google Drive' }; // Core defaults (transport/internet/xdrive/params.go), shown as placeholders so an // empty field keeps meaning "use the core's value". @@ -76,7 +77,7 @@ export default function XDriveForm() { const service = useWatch({ control, name: `${BASE.join('.')}.service` }) as string | undefined; const serviceOptions = XDriveServiceSchema.options.map((value) => { const labelKey = SERVICE_LABEL_KEYS[value]; - return { value, label: labelKey ? t(labelKey) : value }; + return { value, label: labelKey ? t(labelKey) : (SERVICE_NAMES[value] ?? value) }; }); return ( @@ -91,7 +92,7 @@ export default function XDriveForm() { > - {service === 'Google Drive' && + {service === 'google-drive' && GOOGLE_DRIVE_SECRETS.map((key, index) => ( 0 ? (rawStream as StreamSettings) : undefined; if (streamSettings) { healStreamNetworkKey(streamSettings as unknown as Record); + liftLegacyXDriveService(streamSettings as unknown as Record); synthesizeTlsCertUseFile(streamSettings as unknown as Record); const streamRecord = streamSettings as unknown as Record; const xh = streamRecord.xhttpSettings; diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts index 05681202f..c5580ffd4 100644 --- a/frontend/src/lib/xray/inbound-link.ts +++ b/frontend/src/lib/xray/inbound-link.ts @@ -172,9 +172,26 @@ function withLegacyFragmentRanges(finalmask: FinalMaskStreamSettings): FinalMask return changed ? { ...finalmask, tcp } : finalmask; } +// The "filter" mask (xray-core 26.10.21) screens the listener's source IPs, and a client core older +// than it refuses the unknown type. Mirrors dropServerOnlyFinalMasks in internal/sub. +function withoutServerOnlyMasks(finalmask: FinalMaskStreamSettings): FinalMaskStreamSettings { + const next: Record = { ...finalmask }; + let changed = false; + for (const layer of ['tcp', 'udp'] as const) { + const masks = finalmask[layer]; + if (!Array.isArray(masks)) continue; + const kept = masks.filter((mask) => mask?.type !== 'filter'); + if (kept.length === masks.length) continue; + changed = true; + if (kept.length > 0) next[layer] = kept; + else delete next[layer]; + } + return changed ? (next as FinalMaskStreamSettings) : finalmask; +} + function serializeFinalMask(finalmask: FinalMaskStreamSettings | undefined): string { if (!finalmask) return ''; - const shareable = withLegacyFragmentRanges(finalmask); + const shareable = withLegacyFragmentRanges(withoutServerOnlyMasks(finalmask)); return hasShareableFinalMaskValue(shareable) ? JSON.stringify(shareable) : ''; } diff --git a/frontend/src/lib/xray/outbound-form-adapter.ts b/frontend/src/lib/xray/outbound-form-adapter.ts index 02e99c420..41fdd1374 100644 --- a/frontend/src/lib/xray/outbound-form-adapter.ts +++ b/frontend/src/lib/xray/outbound-form-adapter.ts @@ -1,4 +1,5 @@ import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp'; +import { liftLegacyXDriveService } from '@/schemas/protocols/stream/xdrive'; import { OutboundDomainStrategySchema } from '@/schemas/protocols/outbound'; import { AmneziaWGOutboundSettingsSchema } from '@/schemas/protocols/outbound'; import { canEnableTlsFlow } from '@/lib/xray/protocol-capabilities'; @@ -570,6 +571,7 @@ function hydrateStreamForm(stream: Raw): OutboundStreamFormValues { next.network = next.method; } delete next.method; + liftLegacyXDriveService(next); const xh = next.xhttpSettings; if (xh && typeof xh === 'object' && !Array.isArray(xh)) { const xhttp = { ...(xh as Raw) }; diff --git a/frontend/src/lib/xray/protocol-capabilities.ts b/frontend/src/lib/xray/protocol-capabilities.ts index e824efb16..3d8a38e99 100644 --- a/frontend/src/lib/xray/protocol-capabilities.ts +++ b/frontend/src/lib/xray/protocol-capabilities.ts @@ -66,11 +66,14 @@ function hasVlessEncryption(settings: CapabilityProtocolSlice['settings']): bool return isSet(settings?.encryption) || isSet(settings?.decryption); } +// xray-core 26.10.17 (#7182) refuses a VLESS flow on XDRIVE, encrypted or not. export function canEnableTlsFlow(values: CapabilityProtocolSlice): boolean { if (values.protocol !== 'vless') return false; const network = values.streamSettings?.network; const security = values.streamSettings?.security; + if (network === 'xdrive') return false; + // Classic XTLS Vision: raw TCP carried over TLS or REALITY. if (network === 'tcp' && (security === 'tls' || security === 'reality')) return true; diff --git a/frontend/src/pages/inbounds/form/InboundFormModal.tsx b/frontend/src/pages/inbounds/form/InboundFormModal.tsx index 8b7bd5dd7..304a80f96 100644 --- a/frontend/src/pages/inbounds/form/InboundFormModal.tsx +++ b/frontend/src/pages/inbounds/form/InboundFormModal.tsx @@ -948,7 +948,7 @@ export default function InboundFormModal({ { value: 'grpc', label: 'gRPC' }, { value: 'httpupgrade', label: 'HTTPUpgrade' }, { value: 'xhttp', label: 'XHTTP' }, - { value: 'xdrive', label: 'XDRIVE' }, + ...(protocol === Protocols.VLESS ? [{ value: 'xdrive', label: 'XDRIVE' }] : []), ]} /> diff --git a/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx b/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx index 70b2b05f0..947afda29 100644 --- a/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx +++ b/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx @@ -22,7 +22,7 @@ import { FLOW_OPTIONS, HYSTERIA_NETWORK_OPTION, MASQUE_NETWORK_OPTION, - NETWORK_OPTIONS, + networkOptionsFor, PROTOCOL_OPTIONS, SERVER_PROTOCOLS, TARGET_STRATEGY_OPTIONS, @@ -265,9 +265,11 @@ export default function OutboundFormModal({ * wsSettings, etc.) so the DU branch matches. Preserve security if * the new network supports it, otherwise force back to 'none'. */ + // xray-core 26.10.17 (#7182) dials XDRIVE only through Mux, so picking XDRIVE turns Mux on. function onNetworkChange(next: string) { const stream = (methods.getValues('streamSettings') ?? {}) as Record; methods.setValue('streamSettings', applyNetworkChange(protocol, stream, next) as StreamValue); + if (next === 'xdrive') methods.setValue('mux.enabled', true); } function onXmuxToggle(checked: boolean) { @@ -499,7 +501,7 @@ export default function OutboundFormModal({ ? [HYSTERIA_NETWORK_OPTION] : protocol === 'masque' ? [MASQUE_NETWORK_OPTION] - : NETWORK_OPTIONS + : networkOptionsFor(protocol) } /> diff --git a/frontend/src/pages/xray/outbounds/outbound-form-constants.ts b/frontend/src/pages/xray/outbounds/outbound-form-constants.ts index 60c63093c..cb956ea16 100644 --- a/frontend/src/pages/xray/outbounds/outbound-form-constants.ts +++ b/frontend/src/pages/xray/outbounds/outbound-form-constants.ts @@ -47,6 +47,13 @@ export const NETWORK_OPTIONS: { value: string; label: string }[] = [ { value: 'xdrive', label: 'XDRIVE' }, ]; +// xray-core 26.10.17 (#7182) carries only VLESS over XDRIVE. +export function networkOptionsFor(protocol: string): { value: string; label: string }[] { + return protocol === 'vless' + ? NETWORK_OPTIONS + : NETWORK_OPTIONS.filter((option) => option.value !== 'xdrive'); +} + // The hysteria protocol is locked to its own QUIC transport: the selector // shows only this option when the parent protocol is hysteria. export const HYSTERIA_NETWORK_OPTION = { value: 'hysteria', label: 'Hysteria' }; diff --git a/frontend/src/schemas/protocols/stream/finalmask.ts b/frontend/src/schemas/protocols/stream/finalmask.ts index 3cc880d3b..f706489d8 100644 --- a/frontend/src/schemas/protocols/stream/finalmask.ts +++ b/frontend/src/schemas/protocols/stream/finalmask.ts @@ -10,7 +10,9 @@ import { z } from 'zod'; // here would dwarf the rest of the stream module without buying anything // the safety net doesn't already cover. -export const TcpMaskTypeSchema = z.enum(['fragment', 'sudoku', 'header-custom', 'xmc']); +// 'filter' (xray-core 26.10.21) screens a listener's source IPs, so only the server-side +// editors offer it and the share-link generators drop it. +export const TcpMaskTypeSchema = z.enum(['fragment', 'sudoku', 'header-custom', 'xmc', 'filter']); export type TcpMaskType = z.infer; export const TcpMaskSchema = z.object({ @@ -31,6 +33,7 @@ export const UdpMaskTypeSchema = z.enum([ 'sudoku', 'realm', 'udphop', + 'filter', ]); export type UdpMaskType = z.infer; diff --git a/frontend/src/schemas/protocols/stream/masque.ts b/frontend/src/schemas/protocols/stream/masque.ts index b19eb5c84..ac0bdcf5e 100644 --- a/frontend/src/schemas/protocols/stream/masque.ts +++ b/frontend/src/schemas/protocols/stream/masque.ts @@ -22,5 +22,8 @@ export const MasqueStreamSettingsSchema = z.object({ pass: z.string().optional(), headers: z.record(z.string(), z.string()).optional(), warp: MasqueWarpSchema.optional(), + // Client XMUX (xray-core 26.10.17); left unset, the core keeps one connection, so no + // defaults are filled in here. A JSON subscription hands an inbound's value to clients. + xmux: z.record(z.string(), z.unknown()).optional(), }); export type MasqueStreamSettings = z.infer; diff --git a/frontend/src/schemas/protocols/stream/xdrive.ts b/frontend/src/schemas/protocols/stream/xdrive.ts index 10e82d503..7851df2f2 100644 --- a/frontend/src/schemas/protocols/stream/xdrive.ts +++ b/frontend/src/schemas/protocols/stream/xdrive.ts @@ -2,13 +2,28 @@ import { z } from 'zod'; // Both XDRIVE ends carry the same service, folder and secrets; an absent tuning knob takes // the core's default (segmentBytes and concurrency are capped there at 16 MiB / 64). -export const XDriveServiceSchema = z.enum(['Google Drive', 'local', 'template']); +export const XDriveServiceSchema = z.enum(['google-drive', 'local', 'template']); export type XDriveService = z.infer; +// xray-core 26.10.17 (#7182) renamed "Google Drive" and reads the old name only as a deprecated alias. +const canonicalXDriveService = (value: unknown) => + value === 'Google Drive' ? 'google-drive' : value; + +const XDriveServiceInputSchema = z.preprocess(canonicalXDriveService, XDriveServiceSchema); + +/** Lifts a stored stream's legacy service name before an editor reads it unparsed. */ +export function liftLegacyXDriveService(stream: Record): void { + const xdrive = stream.xdriveSettings; + if (!xdrive || typeof xdrive !== 'object' || Array.isArray(xdrive)) return; + const settings = xdrive as Record; + const service = canonicalXDriveService(settings.service); + if (service !== settings.service) stream.xdriveSettings = { ...settings, service }; +} + const MillisSchema = z.number().int().min(0); export const XDriveStreamSettingsSchema = z.object({ - service: XDriveServiceSchema.default('Google Drive'), + service: XDriveServiceInputSchema.default('google-drive'), remoteFolder: z.string().default(''), // Google Drive takes exactly three, in order: ClientID, ClientSecret, RefreshToken. secrets: z.array(z.string()).default([]), @@ -21,5 +36,8 @@ export const XDriveStreamSettingsSchema = z.object({ eagerWindowMs: MillisSchema.optional(), holeTimeoutMs: MillisSchema.optional(), template: z.record(z.string(), z.unknown()).optional(), + // Client XMUX (xray-core 26.10.21); left unset, the core sizes it per service, so no + // defaults are filled in here. A JSON subscription hands an inbound's value to clients. + xmux: z.record(z.string(), z.unknown()).optional(), }); export type XDriveStreamSettings = z.infer; diff --git a/frontend/src/test/__snapshots__/stream.test.ts.snap b/frontend/src/test/__snapshots__/stream.test.ts.snap index 17bfef5b8..66e0e26e0 100644 --- a/frontend/src/test/__snapshots__/stream.test.ts.snap +++ b/frontend/src/test/__snapshots__/stream.test.ts.snap @@ -54,7 +54,7 @@ exports[`NetworkSettingsSchema fixtures > parses xdrive-google-drive byte-stably "1//example-refresh-token", ], "segmentBytes": 524288, - "service": "Google Drive", + "service": "google-drive", "sessionTtlSeconds": 300, }, } diff --git a/frontend/src/test/finalmask-form.test.tsx b/frontend/src/test/finalmask-form.test.tsx index 67a336bb9..598ddc82e 100644 --- a/frontend/src/test/finalmask-form.test.tsx +++ b/frontend/src/test/finalmask-form.test.tsx @@ -29,6 +29,24 @@ describe('FinalMaskForm item types', () => { }); }); +describe('FinalMaskForm filter mask', () => { + // xray-core 26.10.21's filter screens a listener's source IPs and is a no-op on a dialer, so + // only the inbound editor offers it; an older client core refuses the unknown type. + it('offers filter on the server side only', () => { + const value = { tcp: [{ type: 'fragment' as const, settings: {} }], udp: [] }; + const client = renderWithProviders( + , + ); + expect(listSelectOptions('finalmask_tcp_0_type')).not.toContain('Filter (source IPs)'); + client.unmount(); + + renderWithProviders( + , + ); + expect(listSelectOptions('finalmask_tcp_0_type')).toContain('Filter (source IPs)'); + }); +}); + describe('FinalMaskForm salamander password', () => { // xray-core loads a salamander password under 4 bytes and refuses it only when the socket // opens: on an inbound that stops Xray. It counts bytes, so "a€" (4 bytes) is long enough. diff --git a/frontend/src/test/golden/fixtures/stream/xdrive-google-drive.json b/frontend/src/test/golden/fixtures/stream/xdrive-google-drive.json index 966ce4334..4f0879421 100644 --- a/frontend/src/test/golden/fixtures/stream/xdrive-google-drive.json +++ b/frontend/src/test/golden/fixtures/stream/xdrive-google-drive.json @@ -3,7 +3,7 @@ "address": "142.250.0.1", "port": 443, "xdriveSettings": { - "service": "Google Drive", + "service": "google-drive", "remoteFolder": "xray-tunnel", "secrets": [ "000000000000-example.apps.googleusercontent.com", diff --git a/frontend/src/test/inbound-form-modal.test.tsx b/frontend/src/test/inbound-form-modal.test.tsx index 1592133d5..29c6ae1f1 100644 --- a/frontend/src/test/inbound-form-modal.test.tsx +++ b/frontend/src/test/inbound-form-modal.test.tsx @@ -371,7 +371,7 @@ describe('InboundFormModal', () => { network: 'xdrive', security: 'none', xdriveSettings: { - service: 'Google Drive', + service: 'google-drive', remoteFolder: 'xray-tunnel', secrets: ['id.apps.googleusercontent.com', 'client-secret', 'refresh-token'], }, @@ -467,6 +467,54 @@ describe('InboundFormModal', () => { expect(JSON.stringify(finalmask.udp ?? [])).not.toContain('udphop'); }); + // xray-core 26.10.21 (#7206) screens a listener's source IPs with the "filter" mask. + it('saves an inbound filter mask with both source lists', async () => { + const post = vi.mocked(HttpUtil.post); + post.mockClear(); + const filter = { + type: 'filter', + settings: { allowedSourceIPs: ['10.0.0.0/8'], blockedSourceIPs: ['geoip:cn'] }, + }; + const hysteria = new DBInbound({ + id: 45, + port: 443, + listen: '', + protocol: 'hysteria', + remark: 'hy', + enable: true, + settings: { version: 2, clients: [] }, + streamSettings: { + network: 'hysteria', + security: 'tls', + hysteriaSettings: { version: 2, auth: '', udpIdleTimeout: 60 }, + tlsSettings: { + serverName: 'hy.example.com', + alpn: ['h3'], + certificates: [ + { certificateFile: '/etc/ssl/certs/hy.crt', keyFile: '/etc/ssl/private/hy.key' }, + ], + }, + finalmask: { udp: [filter] }, + }, + sniffing: { enabled: false }, + nodeId: null, + shareAddrStrategy: 'listen', + shareAddr: '', + }); + renderCloneLikeEdit(hysteria); + await screen.findByText('geoip:cn'); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + fireEvent.click(primaryButton()); + + const isUpdate = ([url]: unknown[]) => url === '/panel/api/inbounds/update/45'; + await waitFor(() => expect(post.mock.calls.some(isUpdate)).toBe(true)); + const payload = post.mock.calls.find(isUpdate)![1] as { streamSettings: string }; + expect(JSON.parse(payload.streamSettings).finalmask.udp).toEqual([filter]); + }); + // xray-core 26.9: the Wintun adapter type (desc) and the TUN auto-system leak guards. it('saves the TUN adapter type and auto-system options set in the form', async () => { const post = vi.mocked(HttpUtil.post); diff --git a/frontend/src/test/inbound-from-db.test.ts b/frontend/src/test/inbound-from-db.test.ts index 46da7a8ca..979919287 100644 --- a/frontend/src/test/inbound-from-db.test.ts +++ b/frontend/src/test/inbound-from-db.test.ts @@ -226,8 +226,9 @@ describe('protocol-capability helpers with raw coerced shapes', () => { streamSettings: { network: 'xhttp', security: 'none' }, }), ).toBe(false); - // xray-core runs Vision inside VLESS encryption on any transport, RAW and WS included. - for (const network of ['tcp', 'ws', 'grpc', 'xdrive']) { + // xray-core runs Vision inside VLESS encryption on any transport, RAW and WS included, + // but XDRIVE, which refuses a flow since 26.10.17 (#7182). + for (const network of ['tcp', 'ws', 'grpc']) { expect( canEnableTlsFlow({ protocol: 'vless', @@ -236,6 +237,13 @@ describe('protocol-capability helpers with raw coerced shapes', () => { }), ).toBe(true); } + expect( + canEnableTlsFlow({ + protocol: 'vless', + settings: { decryption: dec, encryption: enc }, + streamSettings: { network: 'xdrive', security: 'none' }, + }), + ).toBe(false); expect( canEnableTlsFlow({ protocol: 'vless', diff --git a/frontend/src/test/inbound-link.test.ts b/frontend/src/test/inbound-link.test.ts index 745bdf7a8..f5b368162 100644 --- a/frontend/src/test/inbound-link.test.ts +++ b/frontend/src/test/inbound-link.test.ts @@ -288,6 +288,22 @@ describe('genVlessLink TCP fragment finalmask compatibility', () => { expect(exported).toEqual(finalmask); }); + // xray-core 26.10.21's filter screens the listener's own source IPs; a client core older than + // it refuses the unknown type, and the lists would hand clients the server's IP policy. + it('leaves the inbound-only filter mask out of the link', () => { + const exported = finalmaskFrom( + linkFor({ + tcp: [{ type: 'filter', settings: { allowedSourceIPs: ['10.0.0.0/8'] } }], + udp: [ + { type: 'filter', settings: { blockedSourceIPs: ['geoip:cn'] } }, + { type: 'salamander', settings: { password: 'p' } }, + ], + }), + ); + + expect(exported).toEqual({ udp: [{ type: 'salamander', settings: { password: 'p' } }] }); + }); + it('exports a stored UDP-only finalmask whose empty tcp list was dropped on save', () => { const udpOnly = { udp: [{ type: 'salamander', settings: { password: 'p' } }] }; const inbound = inboundFromDb({ diff --git a/frontend/src/test/outbound-form-modal.test.tsx b/frontend/src/test/outbound-form-modal.test.tsx index 585d545d6..29e19fed6 100644 --- a/frontend/src/test/outbound-form-modal.test.tsx +++ b/frontend/src/test/outbound-form-modal.test.tsx @@ -354,7 +354,7 @@ describe('OutboundFormModal', () => { expect(formItemByLabel('Mux')).toBeDefined(); ws.unmount(); - await openModal( + const xhttp = await openModal( vless('mlkem768x25519plus.native.0rtt.key', { network: 'xhttp', security: 'none', @@ -362,6 +362,68 @@ describe('OutboundFormModal', () => { }), ); expect(formItemByLabel('Flow')).toBeDefined(); + xhttp.unmount(); + + // xray-core 26.10.17 (#7182) refuses a flow on XDRIVE even inside VLESS encryption. + await openModal( + vless('mlkem768x25519plus.native.0rtt.key', { + network: 'xdrive', + security: 'none', + xdriveSettings: { service: 'local', remoteFolder: '/srv/drive' }, + }), + ); + expect(formItemByLabel('Transmission')?.textContent).toContain('XDRIVE'); + expect(formItemByLabel('Flow')).toBeUndefined(); + }); + + // xray-core 26.10.17 (#7182) carries only VLESS over XDRIVE. + it('offers XDRIVE only to a VLESS outbound', async () => { + const transmissionOptions = async (protocol: string) => { + const queryClient = makeTestQueryClient(); + const outbound = { + protocol, + tag: 'out', + settings: { + vnext: [ + { + address: 'example.com', + port: 443, + users: [{ id: 'c9f0c2d0-0000-4000-8000-000000000000', encryption: 'none' }], + }, + ], + }, + streamSettings: { network: 'tcp', security: 'none' }, + }; + const tree = (open: boolean) => ( + + + {}} + onConfirm={() => {}} + /> + + + ); + const view = render(tree(false)); + view.rerender(tree(true)); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + const select = formItemByLabel('Transmission')?.querySelector('.ant-select'); + if (!select) throw new Error('Transmission select not found'); + fireEvent.mouseDown(select.querySelector('.ant-select-selector') ?? select); + const options = Array.from(document.querySelectorAll('.ant-select-item-option')).map((o) => + (o.getAttribute('title') ?? o.textContent ?? '').trim(), + ); + view.unmount(); + return options; + }; + + expect(await transmissionOptions('vmess')).not.toContain('XDRIVE'); + expect(await transmissionOptions('vless')).toContain('XDRIVE'); }); it('saves an XDRIVE stream picked in the transmission selector', async () => { @@ -421,15 +483,83 @@ describe('OutboundFormModal', () => { }); expect(onConfirm).toHaveBeenCalledTimes(1); - const payload = onConfirm.mock.calls[0][0] as { streamSettings: Record }; + const payload = onConfirm.mock.calls[0][0] as { + streamSettings: Record; + mux?: Record; + }; expect(payload.streamSettings).toMatchObject({ network: 'xdrive', xdriveSettings: { - service: 'Google Drive', + service: 'google-drive', remoteFolder: 'xray-tunnel', secrets: ['id.apps.googleusercontent.com', 'client-secret', 'refresh-token'], }, }); + // xray-core 26.10.17 (#7182) refuses an XDRIVE client without Mux. + expect(payload.mux).toMatchObject({ enabled: true }); + }); +}); + +// xray-core 26.10.17 (#7182) renamed the service "google-drive"; an outbound saved before still +// says "Google Drive", which the editor lifts so its secrets stay editable. +describe('OutboundFormModal XDRIVE service name', () => { + it('opens a stored "Google Drive" outbound under the new service name', async () => { + const onConfirm = vi.fn(); + const queryClient = makeTestQueryClient(); + const outbound = { + protocol: 'vless', + tag: 'drive-out', + settings: { + address: 'example.com', + port: 443, + id: 'c9f0c2d0-0000-4000-8000-000000000000', + encryption: 'mlkem768x25519plus.native.0rtt.key', + }, + mux: { enabled: true, concurrency: 8 }, + streamSettings: { + network: 'xdrive', + security: 'none', + xdriveSettings: { + service: 'Google Drive', + remoteFolder: 'xray-tunnel', + secrets: ['id', 'secret', 'token'], + }, + }, + }; + const tree = (open: boolean) => ( + + + {}} + onConfirm={onConfirm} + /> + + + ); + const { rerender } = render(tree(false)); + rerender(tree(true)); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + expect((screen.getByLabelText('Client ID') as HTMLInputElement).value).toBe('id'); + const ok = document.querySelector('.ant-modal-footer .ant-btn-primary') as HTMLElement; + await act(async () => { + fireEvent.click(ok); + }); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + expect(onConfirm).toHaveBeenCalledTimes(1); + const payload = onConfirm.mock.calls[0][0] as { streamSettings: Record }; + expect(payload.streamSettings.xdriveSettings).toMatchObject({ + service: 'google-drive', + secrets: ['id', 'secret', 'token'], + }); }); }); @@ -767,6 +897,33 @@ describe('OutboundFormModal finalmask variables', () => { expect(finalmask.udp).toEqual([{ type: 'header-custom', settings: { client, server: [] } }]); }); + // xray-core 26.10.17 gives noise a Lua `script` and xdns a DoH resolver with its own + // worker count, TLS and QUIC settings; the mask editor only keeps keys it registers. + it('keeps a noise script and the DoH settings of an xdns resolver on save', async () => { + const fixture = { + tcp: [], + udp: [ + { type: 'noise', settings: { reset: 0, script: 'noise.lua' } }, + { + type: 'xdns', + settings: { + domains: [{ names: ['t.example.com'] }], + resolvers: [ + { + addrs: ['https://dns.example.com/dns-query'], + worker: 8, + tlsConfig: { serverName: 'dns.example.com', fingerprint: 'chrome' }, + quicConfig: { congestion: 'bbr', maxIdleTimeout: 30 }, + }, + ], + }, + }, + ], + }; + + expect(await saveFinalmask(structuredClone(fixture))).toEqual(fixture); + }); + it('saves a noise reset typed as a range', async () => { const finalmask = await saveFinalmask( { udp: [{ type: 'noise', settings: { reset: 0, noise: [] } }] }, diff --git a/frontend/src/test/stream.test.ts b/frontend/src/test/stream.test.ts index 245823fe5..5be9db518 100644 --- a/frontend/src/test/stream.test.ts +++ b/frontend/src/test/stream.test.ts @@ -44,3 +44,29 @@ describe('NetworkSettingsSchema method alias', () => { expect((parsed as { network?: string }).network).toBe('grpc'); }); }); + +// xray-core 26.10.17 (#7182) renamed the XDRIVE service, reading "Google Drive" only as an alias. +// XDRIVE's and MASQUE's xmux are client keys a JSON subscription hands on from the inbound. +describe('NetworkSettingsSchema xray-core 26.10.21 keys', () => { + it('folds the "Google Drive" service into google-drive', () => { + const parsed = NetworkSettingsSchema.parse({ + network: 'xdrive', + xdriveSettings: { service: 'Google Drive', remoteFolder: 'f', secrets: ['a', 'b', 'c'] }, + }) as { xdriveSettings: { service: string } }; + expect(parsed.xdriveSettings.service).toBe('google-drive'); + }); + + it('keeps the XDRIVE and MASQUE xmux', () => { + const xmux = { maxConnections: 2, hMaxReusableSecs: '1800-3000' }; + const xdrive = NetworkSettingsSchema.parse({ + network: 'xdrive', + xdriveSettings: { service: 'local', remoteFolder: '/srv/drive', xmux }, + }) as { xdriveSettings: { xmux?: unknown } }; + const masque = NetworkSettingsSchema.parse({ + network: 'masque', + masqueSettings: { host: 'masque.example.com', xmux }, + }) as { masqueSettings: { xmux?: unknown } }; + expect(xdrive.xdriveSettings.xmux).toEqual(xmux); + expect(masque.masqueSettings.xmux).toEqual(xmux); + }); +}); diff --git a/go.mod b/go.mod index 70afbf3a9..2139b5e21 100644 --- a/go.mod +++ b/go.mod @@ -28,7 +28,7 @@ require ( github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/valyala/fasthttp v1.75.0 github.com/xlzd/gotp v0.1.0 - github.com/xtls/xray-core v1.260327.1-0.20261010092107-701af60772cd + github.com/xtls/xray-core v1.260327.1-0.20261021115930-1b1647cd9ddf go.uber.org/atomic v1.12.0 golang.org/x/crypto v0.58.0 golang.org/x/net v0.61.0 @@ -50,7 +50,6 @@ require ( github.com/bytedance/gopkg v0.1.4 // indirect github.com/bytedance/sonic v1.15.4 // indirect github.com/bytedance/sonic/loader v0.5.2 // indirect - github.com/cloudflare/circl v1.6.5 // indirect github.com/cloudwego/base64x v0.1.7 // indirect github.com/ebitengine/purego v0.11.1 // indirect github.com/gabriel-vasile/mimetype v1.4.15 // indirect @@ -73,7 +72,7 @@ require ( github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/juju/ratelimit v1.0.2 // indirect - github.com/klauspost/cpuid/v2 v2.4.0 // indirect + github.com/klauspost/cpuid/v2 v2.4.1 // indirect github.com/koron/go-ssdp v0.9.1 // indirect github.com/leodido/go-urn v1.5.0 // indirect github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138 // indirect @@ -103,13 +102,13 @@ require ( github.com/vishvananda/netlink v1.3.1 // indirect github.com/vishvananda/netns v0.0.5 // indirect github.com/wlynxg/anet v0.0.5 // indirect - github.com/xtls/reality v0.0.0-20260921001439-3c98159dee38 // indirect + github.com/xtls/reality v0.0.0-20261014101318-83f621788ca8 // indirect github.com/yuin/gopher-lua v1.1.2 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.mongodb.org/mongo-driver/v2 v2.9.2 // indirect go4.org/netipx v0.0.0-20260823151212-3075585bcbeb // indirect golang.org/x/arch v0.32.0 // indirect - golang.org/x/crypto/x509roots/fallback v0.0.0-20261009200856-99e4382b128e // indirect + golang.org/x/crypto/x509roots/fallback v0.0.0-20261020153306-9f2b28d4331a // indirect golang.org/x/exp v0.0.0-20261009195045-ca0d7ba23607 // indirect golang.org/x/sync v0.24.0 // indirect golang.org/x/time v0.16.0 // indirect diff --git a/go.sum b/go.sum index 913f2751d..185b3114e 100644 --- a/go.sum +++ b/go.sum @@ -19,8 +19,6 @@ github.com/bytedance/sonic/loader v0.5.2/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCc github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/cloudflare/circl v1.6.5 h1:O64F26HEqNhznd/hrC5KZXVKYuKM2rx4deZDTc4ihQA= -github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGnz5OstK6c= github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI= github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -120,8 +118,8 @@ github.com/juju/ratelimit v1.0.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI= github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk= github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= -github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= -github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= +github.com/klauspost/cpuid/v2 v2.4.1 h1:I9KxadAkvo+QjtcEE2f2tyfEvmIla/T0m+14PrZNl0U= +github.com/klauspost/cpuid/v2 v2.4.1/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= github.com/koron/go-ssdp v0.9.1 h1:zvxbAAuJftJIZ8Jh8mda+LI7V92hYZf/sKprmOxpxwA= github.com/koron/go-ssdp v0.9.1/go.mod h1:C43c047jWkDaeg9YuZlSh/QGqOieuWV6dbhWi/jcaLk= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -224,10 +222,10 @@ github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/xlzd/gotp v0.1.0 h1:37blvlKCh38s+fkem+fFh7sMnceltoIEBYTVXyoa5Po= github.com/xlzd/gotp v0.1.0/go.mod h1:ndLJ3JKzi3xLmUProq4LLxCuECL93dG9WASNLpHz8qg= -github.com/xtls/reality v0.0.0-20260921001439-3c98159dee38 h1:Q5KwiMm+WRyw2kwPW4+fIQxNio1CyrWo/eAEaAHxl/Q= -github.com/xtls/reality v0.0.0-20260921001439-3c98159dee38/go.mod h1:/YQ6FwmAtkDuo8K3ujKQH1Br5eGNFauBipdl1gdiebk= -github.com/xtls/xray-core v1.260327.1-0.20261010092107-701af60772cd h1:Am/9Pzw5PZr7+hMN1Vs5faeSXRoutnrwMAgtUDnh/J8= -github.com/xtls/xray-core v1.260327.1-0.20261010092107-701af60772cd/go.mod h1:YAACF9L/Pu1GznixHOBtrBqVcE95nhFjgU+gIEdWKlo= +github.com/xtls/reality v0.0.0-20261014101318-83f621788ca8 h1:RjLnVvlzK4q6zWaSBPNRGsrgx2fbJDeUWffjw574uzs= +github.com/xtls/reality v0.0.0-20261014101318-83f621788ca8/go.mod h1:ptlSkJ8k/7IDauNH0SA96JPOo+nYGLTv2Y3UMpwvAsY= +github.com/xtls/xray-core v1.260327.1-0.20261021115930-1b1647cd9ddf h1:Eqz6sj4Up2NtymyVY2UtXNpHZqJGrMwKsXnGOySyUNI= +github.com/xtls/xray-core v1.260327.1-0.20261021115930-1b1647cd9ddf/go.mod h1:rbK8dL0A1f4uYpNa3ljvB1U5qrcb4KQ4Pxb8yGdWZXo= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/gopher-lua v0.0.0-20190206043414-8bfc7677f583/go.mod h1:gqRgreBUhTSL0GeU64rtZ3Uq3wtjOa/TB2YfrtkCbVQ= @@ -249,8 +247,8 @@ golang.org/x/arch v0.32.0 h1:NjGf8/xGf5JgAJ4dw4NtME5R1L7xatagqAg95T3LgII= golang.org/x/arch v0.32.0/go.mod h1:KcJSod3cqT2dKcjBxqTyGfbumNikqU9p5tHJinPJnuY= golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= -golang.org/x/crypto/x509roots/fallback v0.0.0-20261009200856-99e4382b128e h1:jsB4A6J5vF93GeMqp72mLAOcFfGXgHtxpgeLk7OJB4M= -golang.org/x/crypto/x509roots/fallback v0.0.0-20261009200856-99e4382b128e/go.mod h1:HPze8vhfG6fO06AM+VSvxRm4E3+5Yk375mgrJ5M2z1E= +golang.org/x/crypto/x509roots/fallback v0.0.0-20261020153306-9f2b28d4331a h1:V5oicZXS0oqZx30gY/apgSVD7YvNBfrIOdcMnlelJY4= +golang.org/x/crypto/x509roots/fallback v0.0.0-20261020153306-9f2b28d4331a/go.mod h1:HPze8vhfG6fO06AM+VSvxRm4E3+5Yk375mgrJ5M2z1E= golang.org/x/exp v0.0.0-20261009195045-ca0d7ba23607 h1:o259es6R0mYtmCeID0xv+dkp5kQSf6qp8zs0j4MdUaI= golang.org/x/exp v0.0.0-20261009195045-ca0d7ba23607/go.mod h1:SRjTzy5WcYqRRqquLUvHeeovSwLO0CZ3xcltZNO4Fck= golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= diff --git a/internal/sub/json_service.go b/internal/sub/json_service.go index e6cd07e89..6e01f24c8 100644 --- a/internal/sub/json_service.go +++ b/internal/sub/json_service.go @@ -715,6 +715,7 @@ func (s *SubJsonService) streamData(stream string, clientKey string) map[string] } } delete(streamSettings, "sockopt") + dropServerOnlyFinalMasks(streamSettings["finalmask"]) // The inbound's own hop range becomes a mask first, so the global default yields to it. maskcompat.UpgradeLegacyUDPHop(streamSettings["finalmask"]) diff --git a/internal/sub/json_service_test.go b/internal/sub/json_service_test.go index 6b8f88b7f..dac398a25 100644 --- a/internal/sub/json_service_test.go +++ b/internal/sub/json_service_test.go @@ -176,6 +176,27 @@ func TestSubJsonServiceMergesWithExistingFinalMask(t *testing.T) { } } +// The inbound "filter" mask (xray-core 26.10.21) screens the listener's source IPs: it would hand +// clients the server's IP policy, and a client core older than it refuses the unknown type. +func TestSubJsonServiceDropsTheServerOnlyFilterMask(t *testing.T) { + svc := NewSubJsonService("", "", "", "", nil) + stream := svc.streamData(`{"network":"kcp","security":"none","finalmask":{ + "tcp":[{"type":"filter","settings":{"allowedSourceIPs":["10.0.0.0/8"]}}], + "udp":[{"type":"filter","settings":{"blockedSourceIPs":["geoip:cn"]}},{"type":"mkcp-legacy","settings":{"header":""}}]}}`, "") + + finalmask, _ := stream["finalmask"].(map[string]any) + if _, ok := finalmask["tcp"]; ok { + t.Fatalf("finalmask.tcp = %#v, want it gone with its only mask", finalmask["tcp"]) + } + udp, _ := finalmask["udp"].([]any) + if len(udp) != 1 { + t.Fatalf("finalmask.udp = %#v, want only the mkcp-legacy mask", udp) + } + if mask, _ := udp[0].(map[string]any); mask["type"] != "mkcp-legacy" { + t.Fatalf("finalmask.udp[0] = %#v, want mkcp-legacy", mask) + } +} + func TestSubJsonServiceNoFinalMaskWhenEmpty(t *testing.T) { svc := NewSubJsonService("", "", "", "", nil) stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "") diff --git a/internal/sub/service.go b/internal/sub/service.go index 48fbacc1e..eea2bbd85 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -1203,8 +1203,11 @@ func vlessClientEncryption(settings map[string]any) string { } // vlessFlowAllowed mirrors inboundCanEnableTlsFlow in internal/web/service: Vision runs over -// TLS/REALITY on raw TCP, or inside VLESS encryption on any transport. +// TLS/REALITY on raw TCP, or inside VLESS encryption on any transport but XDRIVE. func vlessFlowAllowed(network, security string, settings map[string]any) bool { + if network == "xdrive" { + return false + } if network == "tcp" && (security == "tls" || security == "reality") { return true } @@ -2706,6 +2709,28 @@ var validFinalMaskTCPTypes = map[string]struct{}{ "xmc": {}, } +// dropServerOnlyFinalMasks removes the inbound "filter" mask (xray-core 26.10.21, #7206): it only +// screens the listener's source IPs, and a client core older than it refuses the unknown type. +func dropServerOnlyFinalMasks(finalmask any) { + fm, _ := finalmask.(map[string]any) + for _, layer := range []string{"tcp", "udp"} { + masks, ok := fm[layer].([]any) + if !ok { + continue + } + kept := slices.DeleteFunc(masks, func(entry any) bool { + mask, _ := entry.(map[string]any) + maskType, _ := mask["type"].(string) + return strings.EqualFold(maskType, "filter") + }) + if len(kept) == 0 { + delete(fm, layer) + } else { + fm[layer] = kept + } + } +} + // applyKcpShareParams reconstructs legacy KCP share-link fields from either // the historical kcpSettings.header/seed shape or the current finalmask model. // This keeps subscription output compatible while avoiding panics when older diff --git a/internal/sub/service_flow_test.go b/internal/sub/service_flow_test.go index 696f267dd..882a6ae4e 100644 --- a/internal/sub/service_flow_test.go +++ b/internal/sub/service_flow_test.go @@ -33,6 +33,7 @@ func TestVlessFlowAllowed(t *testing.T) { {"xhttp tls vlessenc", "xhttp", "tls", enc, true}, {"xhttp reality no vlessenc", "xhttp", "reality", noEnc, false}, {"ws tls", "ws", "tls", noEnc, false}, + {"xdrive vlessenc (xray-core #7182)", "xdrive", "none", enc, false}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { diff --git a/internal/web/service/golden_fixtures_xray_test.go b/internal/web/service/golden_fixtures_xray_test.go index e60329822..1fe7d8d05 100644 --- a/internal/web/service/golden_fixtures_xray_test.go +++ b/internal/web/service/golden_fixtures_xray_test.go @@ -248,6 +248,9 @@ func TestGoldenInboundFullFixturesBuildInXray(t *testing.T) { // TestGoldenStreamFixturesBuildInXray attaches the transport fragments — whole // stream sections, the security block, sockopt and finalmask — to an inbound. +// goldenVlessDecryption carries an XDRIVE fixture, which xray-core 26.10.17 runs only inside VLESS encryption. +const goldenVlessDecryption = "mlkem768x25519plus.native.600s.mMFxPe7lz5xoq2qBk22cQYefu5fpc_2dGR8lMOKem0E" + func TestGoldenStreamFixturesBuildInXray(t *testing.T) { for _, category := range []string{"stream", "security", "sockopt", "finalmask"} { for name, fixture := range goldenFixtures(t, category) { @@ -263,11 +266,15 @@ func TestGoldenStreamFixturesBuildInXray(t *testing.T) { case "finalmask": stream["finalmask"] = fixture } + decryption := "none" + if stream["network"] == "xdrive" { + decryption = goldenVlessDecryption + } inbound := map[string]any{ "tag": "golden-in", "listen": "127.0.0.1", "port": 8443, "protocol": "vless", "settings": map[string]any{ "clients": []any{map[string]any{"id": "b831381d-6324-4d53-ad4f-8cda48b30811", "email": "golden"}}, - "decryption": "none", + "decryption": decryption, }, "streamSettings": stream, } diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index 0de8e9381..0e4175861 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -666,6 +666,7 @@ func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) { } inbound.StreamSettings = canonicalizeStreamNetworkKey(inbound.StreamSettings) inbound.StreamSettings = canonicalizeLegacyXdnsMasks(inbound.StreamSettings) + inbound.StreamSettings = canonicalizeXDriveService(inbound.StreamSettings) } // canonicalizeLegacyXdnsMasks stores an xdns mask posted in a pre-26.10.10 shape in @@ -1006,6 +1007,11 @@ func validateFinalMaskSalamander(streamSettings string) error { return maskcompat.CheckSalamanderPasswords(stream["finalmask"]) } +const ( + xdriveServiceGoogleDrive = "google-drive" + xdriveServiceGoogleDriveLegacy = "Google Drive" +) + // validateXDriveStorage refuses at save the XDRIVE storage xray-core refuses when an inbound starts // listening (stopping every inbound) or an outbound dials. func validateXDriveStorage(streamSettings string) error { @@ -1028,7 +1034,7 @@ func validateXDriveStorage(streamSettings string) error { if !hasFolder { return common.NewError(`XDRIVE needs a "remoteFolder": the local folder both ends share.`) } - case "Google Drive": + case xdriveServiceGoogleDrive, xdriveServiceGoogleDriveLegacy: if !hasFolder { return common.NewError(`XDRIVE needs a "remoteFolder": the id of the shared Google Drive folder.`) } @@ -1041,11 +1047,52 @@ func validateXDriveStorage(streamSettings string) error { } return validateXDriveTemplate(xd.Template) default: - return common.NewError(fmt.Sprintf("unsupported XDRIVE service %q: use local, Google Drive or template.", xd.Service)) + return common.NewError(fmt.Sprintf("unsupported XDRIVE service %q: use local, google-drive or template.", xd.Service)) } return nil } +// validateXDriveInbound refuses what xray-core 26.10.17 refuses on an XDRIVE inbound (#7182): any +// protocol but VLESS, and VLESS without "decryption". Flow is never emitted there and the port is single. +func validateXDriveInbound(protocol, settings, streamSettings string) error { + var stream struct { + Network string `json:"network"` + } + if json.Unmarshal([]byte(streamSettings), &stream) != nil || stream.Network != "xdrive" { + return nil + } + if protocol != string(model.VLESS) { + return common.NewError(fmt.Sprintf("XDRIVE carries only VLESS, not %s.", protocol)) + } + var vless struct { + Decryption string `json:"decryption"` + } + _ = json.Unmarshal([]byte(settings), &vless) + if !vlessEncValueSet(vless.Decryption) { + return common.NewError(`XDRIVE needs VLESS encryption: set the inbound's Decryption.`) + } + return nil +} + +// canonicalizeXDriveService stores the "google-drive" service xray-core 26.10.17 names +// (#7182) in place of "Google Drive", which it still reads with a deprecation warning. +func canonicalizeXDriveService(streamSettings string) string { + var stream map[string]any + if json.Unmarshal([]byte(streamSettings), &stream) != nil { + return streamSettings + } + xd, _ := stream["xdriveSettings"].(map[string]any) + if xd == nil || xd["service"] != xdriveServiceGoogleDriveLegacy { + return streamSettings + } + xd["service"] = xdriveServiceGoogleDrive + out, err := json.MarshalIndent(stream, "", " ") + if err != nil { + return streamSettings + } + return string(out) +} + // validateXDriveTemplate applies the checks of xray-core's newTemplateStorage // (transport/internet/xdrive/template.go), which only run once the listener starts. func validateXDriveTemplate(raw json.RawMessage) error { @@ -1254,6 +1301,9 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo if err := validateXDriveStorage(inbound.StreamSettings); err != nil { return inbound, false, err } + if err := validateXDriveInbound(string(inbound.Protocol), inbound.Settings, inbound.StreamSettings); err != nil { + return inbound, false, err + } if err := validateShadowsocks2022Inbound(inbound.Protocol, inbound.Settings); err != nil { return inbound, false, err } @@ -1905,6 +1955,9 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, if err := validateXDriveStorage(inbound.StreamSettings); err != nil { return inbound, false, err } + if err := validateXDriveInbound(string(inbound.Protocol), inbound.Settings, inbound.StreamSettings); err != nil { + return inbound, false, err + } if err := validateShadowsocks2022Inbound(inbound.Protocol, inbound.Settings); err != nil { return inbound, false, err } diff --git a/internal/web/service/inbound_protocol.go b/internal/web/service/inbound_protocol.go index 12c1993fa..10762a2a6 100644 --- a/internal/web/service/inbound_protocol.go +++ b/internal/web/service/inbound_protocol.go @@ -28,7 +28,7 @@ func inboundShadowsocksMethod(protocol, settings string) string { } // inboundCanEnableTlsFlow mirrors the frontend's canEnableTlsFlow: xray-core runs Vision over -// TLS/REALITY on raw TCP, or inside VLESS encryption (encryption.CommonConn) on any transport. +// TLS/REALITY on raw TCP, or inside VLESS encryption on any transport but XDRIVE (#7182). func inboundCanEnableTlsFlow(protocol, streamSettings, settings string) bool { if protocol != string(model.VLESS) { return false @@ -37,8 +37,11 @@ func inboundCanEnableTlsFlow(protocol, streamSettings, settings string) bool { Network string `json:"network"` Security string `json:"security"` } - if json.Unmarshal([]byte(streamSettings), &stream) == nil && stream.Network == "tcp" && - (stream.Security == "tls" || stream.Security == "reality") { + _ = json.Unmarshal([]byte(streamSettings), &stream) + if stream.Network == "xdrive" { + return false + } + if stream.Network == "tcp" && (stream.Security == "tls" || stream.Security == "reality") { return true } return vlessEncryptionEnabled(settings) diff --git a/internal/web/service/inbound_protocol_test.go b/internal/web/service/inbound_protocol_test.go index 2bca4b4d4..fb2deef3f 100644 --- a/internal/web/service/inbound_protocol_test.go +++ b/internal/web/service/inbound_protocol_test.go @@ -26,10 +26,11 @@ func TestInboundCanEnableTlsFlow(t *testing.T) { {"vmess tcp tls", string(model.VMESS), `{"network":"tcp","security":"tls"}`, "", false}, {"empty stream", string(model.VLESS), "", "", false}, - // xray-core runs Vision inside VLESS encryption on any transport (encryption.CommonConn). + // xray-core runs Vision inside VLESS encryption on any transport (encryption.CommonConn) + // but XDRIVE, which refuses a flow since 26.10.17 (#7182). {"vless raw vlessenc", string(model.VLESS), `{"network":"tcp","security":"none"}`, `{"decryption":"mlkem768x25519plus.native.600s.mMFxPe7lz5xoq2qBk22cQYefu5fpc_2dGR8lMOKem0E","encryption":"mlkem768x25519plus.native.0rtt.hT4AY_tPWY9NVuKR3BIXxXq6zx9DqN2X86QPYW09XEM"}`, true}, {"vless ws vlessenc", string(model.VLESS), `{"network":"ws","security":"none"}`, `{"encryption":"` + vlessEncValue + `"}`, true}, - {"vless xdrive vlessenc", string(model.VLESS), `{"network":"xdrive","security":"none"}`, `{"decryption":"` + vlessEncValue + `"}`, true}, + {"vless xdrive vlessenc", string(model.VLESS), `{"network":"xdrive","security":"none"}`, `{"decryption":"` + vlessEncValue + `"}`, false}, {"vless xdrive no encryption", string(model.VLESS), `{"network":"xdrive","security":"none"}`, `{"decryption":"none"}`, false}, // XHTTP + VLESS encryption (the #5157 case). diff --git a/internal/web/service/inbound_xdrive_test.go b/internal/web/service/inbound_xdrive_test.go index 438f7eee5..a580315ca 100644 --- a/internal/web/service/inbound_xdrive_test.go +++ b/internal/web/service/inbound_xdrive_test.go @@ -1,20 +1,128 @@ package service import ( + "encoding/json" + "slices" "strings" "testing" + "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" ) func xdriveInbound(port int, xdriveSettings string) *model.Inbound { return &model.Inbound{ Enable: true, Listen: "127.0.0.1", Port: port, Protocol: model.VLESS, - Settings: `{"clients":[],"decryption":"none"}`, + Settings: `{"clients":[],"decryption":"` + goldenVlessDecryption + `"}`, StreamSettings: `{"network":"xdrive","security":"none","xdriveSettings":` + xdriveSettings + `}`, } } +// xray-core 26.10.17 (#7182) refuses to start an XDRIVE inbound that is not VLESS with +// "decryption" set, and with it every other inbound of the config. +func TestSavePathsRefuseXDriveOutsideVlessEncryption(t *testing.T) { + const local = `{"service":"local","remoteFolder":"/tmp/drive"}` + vmess := xdriveInbound(18490, local) + vmess.Protocol = model.VMESS + vmess.Settings = `{"clients":[]}` + plain := xdriveInbound(18491, local) + plain.Settings = `{"clients":[],"decryption":"none"}` + tests := []struct { + name string + save func(t *testing.T) error + want string + }{ + {"vmess inbound", func(t *testing.T) error { + _, _, err := (&InboundService{}).AddInbound(vmess) + return err + }, "XDRIVE carries only VLESS, not vmess"}, + {"vless inbound without decryption", func(t *testing.T) error { + _, _, err := (&InboundService{}).AddInbound(plain) + return err + }, "XDRIVE needs VLESS encryption"}, + {"xray template inbound without decryption", func(t *testing.T) error { + return (&XraySettingService{}).SaveXraySetting(`{"inbounds":[{"tag":"drive","port":18492,"protocol":"vless", + "settings":` + plain.Settings + `,"streamSettings":` + plain.StreamSettings + `}], + "outbounds":[{"protocol":"freedom","tag":"direct"}]}`) + }, "XDRIVE needs VLESS encryption"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + setupBulkDB(t) + if err := tc.save(t); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("save err = %v, want one containing %q", err, tc.want) + } + }) + } +} + +// An XDRIVE inbound saved before xray-core 26.10.17 without VLESS encryption makes the core refuse +// the whole config at startup; the build leaves that inbound out so the others keep running. +func TestGetXrayConfig_LeavesOutAStoredXDriveInboundTheCoreRefuses(t *testing.T) { + seedVlessInbound(t, "plain-drive", 43130, nil) + if err := database.GetDB().Model(&model.Inbound{}).Where("tag = ?", "plain-drive"). + Update("stream_settings", `{"network":"xdrive","security":"none","xdriveSettings":{"service":"local","remoteFolder":"/tmp/shared"}}`).Error; err != nil { + t.Fatalf("set stream: %v", err) + } + cfg, err := (&XrayService{}).GetXrayConfig() + if err != nil { + t.Fatalf("GetXrayConfig: %v", err) + } + for _, ib := range cfg.InboundConfigs { + if ib.Tag == "plain-drive" { + t.Fatalf("XDRIVE inbound without decryption emitted: %s", ib.Settings) + } + } +} + +// A template XDRIVE outbound from before 26.10.17 lacks the VLESS encryption and mux the core now +// requires; the build drops only that one instead of letting the core refuse the whole config. +func TestGetXrayConfig_DropsATemplateXDriveOutboundTheCoreRefuses(t *testing.T) { + setupConflictDB(t) + const stream = `{"network":"xdrive","security":"none","xdriveSettings":{"service":"local","remoteFolder":"/tmp/shared"}}` + template := `{"outbounds":[{"protocol":"freedom","tag":"direct"}, + {"protocol":"vless","tag":"drive-plain","streamSettings":` + stream + `, + "settings":{"address":"10.0.0.1","port":443,"id":"11111111-2222-4333-8444-555555555555","encryption":"none"}}, + {"protocol":"vless","tag":"drive-encrypted","streamSettings":` + stream + `,"mux":{"enabled":true,"concurrency":8}, + "settings":{"address":"10.0.0.1","port":443,"id":"11111111-2222-4333-8444-555555555555", + "encryption":"mlkem768x25519plus.native.0rtt.mMFxPe7lz5xoq2qBk22cQYefu5fpc_2dGR8lMOKem0E"}}]}` + if err := (&SettingService{}).saveSetting("xrayTemplateConfig", template); err != nil { + t.Fatalf("seed template: %v", err) + } + + cfg, err := (&XrayService{}).GetXrayConfig() + if err != nil { + t.Fatalf("GetXrayConfig: %v", err) + } + var outbounds []struct { + Tag string `json:"tag"` + } + if err := json.Unmarshal(cfg.OutboundConfigs, &outbounds); err != nil { + t.Fatalf("decode emitted outbounds: %v", err) + } + var tags []string + for _, ob := range outbounds { + tags = append(tags, ob.Tag) + } + if !slices.Equal(tags, []string{"direct", "drive-encrypted"}) { + t.Fatalf("emitted outbound tags = %v, want [direct drive-encrypted]", tags) + } +} + +// xray-core 26.10.17 renamed the service to "google-drive" and reads "Google Drive" only as a +// deprecated alias, so a save posting the old name stores the new one. +func TestAddInboundStoresTheGoogleDriveServiceUnderItsNewName(t *testing.T) { + setupBulkDB(t) + in, _, err := (&InboundService{}).AddInbound(xdriveInbound(18493, + `{"service":"Google Drive","remoteFolder":"f","secrets":["id","secret","token"]}`)) + if err != nil { + t.Fatalf("AddInbound: %v", err) + } + if !strings.Contains(in.StreamSettings, `"service": "google-drive"`) { + t.Fatalf("stored streamSettings = %s, want service google-drive", in.StreamSettings) + } +} + // xray-core opens XDRIVE storage when the listener starts; a storage it refuses there stops every inbound. func TestAddInboundRefusesXDriveStorageTheCoreCannotOpen(t *testing.T) { tests := []struct { diff --git a/internal/web/service/xray.go b/internal/web/service/xray.go index d235f9368..bb1f2a711 100644 --- a/internal/web/service/xray.go +++ b/internal/web/service/xray.go @@ -184,6 +184,7 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) { // pre-26.9.9 quicParams.udpHop, which the core ignores, or a udphop not last, which it cannot dial. xrayConfig.OutboundConfigs = healOutboundStreams(xrayConfig.OutboundConfigs, liftXhttpSessionIDKeys, upgradeStreamLegacyXdns, upgradeStreamLegacyUDPHop, normalizeStreamUDPHop) + xrayConfig.OutboundConfigs = dropXDriveOutboundsRefusedByCore(xrayConfig.OutboundConfigs) // Bridge amneziawg outbounds before anything else reads OutboundConfigs; // the core has no amneziawg proxy and would reject the raw entry. if err := transformAmneziaWGOutbounds(xrayConfig); err != nil { @@ -410,6 +411,7 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) { inboundConfig := inbound.GenXrayInboundConfig() xrayConfig.InboundConfigs = append(xrayConfig.InboundConfigs, *inboundConfig) } + xrayConfig.InboundConfigs = dropInboundsRefusedByXDrive(xrayConfig.InboundConfigs) // Merge subscription-derived outbounds (if any) into the final outbounds array. // These are additive: each subscription is placed before or after the template @@ -471,6 +473,8 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) { injectNodeEgresses(xrayConfig, nodes) } + xrayConfig.RouterConfig = dropRefusedScript(xrayConfig.RouterConfig, "routing", routingScriptRefused) + xrayConfig.DNSConfig = dropRefusedScript(xrayConfig.DNSConfig, "dns", dnsScriptRefused) return xrayConfig, nil } @@ -1708,6 +1712,103 @@ func healOutboundStreams(raw json_util.RawMessage, heals ...func(stream map[stri return raw } +// dropInboundsRefusedByXDrive keeps out an XDRIVE inbound stored before xray-core 26.10.17 made it +// VLESS-encryption only (#7182); the core would refuse the whole config and stop every inbound. +func dropInboundsRefusedByXDrive(inbounds []xray.InboundConfig) []xray.InboundConfig { + return slices.DeleteFunc(inbounds, func(ib xray.InboundConfig) bool { + err := validateXDriveInbound(ib.Protocol, string(ib.Settings), string(ib.StreamSettings)) + if err != nil { + logger.Warningf("Inbound %q: not started, %v", ib.Tag, err) + } + return err != nil + }) +} + +// dropXDriveOutboundsRefusedByCore keeps out an XDRIVE outbound stored before xray-core 26.10.17 +// required VLESS encryption and mux on it (#7182), which would stop the whole core at startup. +func dropXDriveOutboundsRefusedByCore(raw json_util.RawMessage) json_util.RawMessage { + var outbounds []json.RawMessage + if len(raw) == 0 || json.Unmarshal(raw, &outbounds) != nil { + return raw + } + kept := make([]json.RawMessage, 0, len(outbounds)) + for _, ob := range outbounds { + var probe struct { + Tag string `json:"tag"` + StreamSettings struct { + Network string `json:"network"` + } `json:"streamSettings"` + } + if json.Unmarshal(ob, &probe) == nil && probe.StreamSettings.Network == "xdrive" { + if err := xray.ValidateOutboundConfig(ob); err != nil { + logger.Warningf("Outbound %q: not started, xray-core refuses it: %v", probe.Tag, err) + continue + } + } + kept = append(kept, ob) + } + if len(kept) == len(outbounds) { + return raw + } + if rewritten, err := json.Marshal(kept); err == nil { + return rewritten + } + return raw +} + +// routingScriptRefused mirrors xray-core 26.10.17's RouterConfig.Build (#7136): a routing +// "script" replaces rules and domainStrategy, so the core refuses a config holding both. +func routingScriptRefused(routing map[string]any) bool { + script, _ := routing["script"].(string) + rules, _ := routing["rules"].([]any) + return script != "" && (routing["domainStrategy"] != nil || len(rules) > 0) +} + +// dnsScriptRefused mirrors xray-core 26.10.17's DNSConfig.Build (#7136): a dns "script" refuses the +// fallback and parallel switches, and any server scoped by domains, skipFallback or finalQuery. +func dnsScriptRefused(dns map[string]any) bool { + if script, _ := dns["script"].(string); script == "" { + return false + } + for _, key := range []string{"disableFallback", "disableFallbackIfMatch", "enableParallelQuery"} { + if on, _ := dns[key].(bool); on { + return true + } + } + servers, _ := dns["servers"].([]any) + return slices.ContainsFunc(servers, func(entry any) bool { + server, _ := entry.(map[string]any) + skipFallback, _ := server["skipFallback"].(bool) + finalQuery, _ := server["finalQuery"].(bool) + return hasDNSDomains(server["domains"]) || skipFallback || finalQuery + }) +} + +func hasDNSDomains(domains any) bool { + switch v := domains.(type) { + case []any: + return len(v) > 0 + case string: + return v != "" + } + return false +} + +// dropRefusedScript drops a section's Lua "script" the core would refuse, logging why: the +// panel's own rules and DNS settings stay, since its stats path depends on the api rule. +func dropRefusedScript(raw json_util.RawMessage, section string, refused func(map[string]any) bool) json_util.RawMessage { + var parsed map[string]any + if len(raw) == 0 || json.Unmarshal(raw, &parsed) != nil || !refused(parsed) { + return raw + } + logger.Warningf("%s: ignoring \"script\" %v, xray-core refuses it beside this %s section's other settings", section, parsed["script"], section) + delete(parsed, "script") + if rewritten, err := json.Marshal(parsed); err == nil { + return rewritten + } + return raw +} + func upgradeStreamLegacyXdns(stream map[string]any) bool { return maskcompat.UpgradeLegacyXdns(stream["finalmask"]) } diff --git a/internal/web/service/xray_config_clients_test.go b/internal/web/service/xray_config_clients_test.go index 5b25614a5..2d3e9437d 100644 --- a/internal/web/service/xray_config_clients_test.go +++ b/internal/web/service/xray_config_clients_test.go @@ -109,14 +109,19 @@ func TestGetXrayConfig_EnabledClientsStillEmitted(t *testing.T) { } // A flow kept from a TCP+REALITY stream makes xray demand Vision on a transport that cannot run it -// without VLESS encryption: "XTLS only supports TLS and REALITY directly". +// without VLESS encryption: "XTLS only supports TLS and REALITY directly". XDRIVE refuses it even +// inside VLESS encryption since xray-core 26.10.17 (#7182). func TestGetXrayConfig_OmitsVisionFlowOnIneligibleStream(t *testing.T) { cases := []struct { - name string - stream string + name string + stream string + settings string }{ - {"xdrive", `{"network":"xdrive","security":"none","xdriveSettings":{"service":"local","remoteFolder":"/tmp/shared"}}`}, - {"ws over tls", `{"network":"ws","security":"tls","wsSettings":{"path":"/"}}`}, + { + "xdrive vlessenc", `{"network":"xdrive","security":"none","xdriveSettings":{"service":"local","remoteFolder":"/tmp/shared"}}`, + `{"clients":[],"decryption":"` + goldenVlessDecryption + `"}`, + }, + {"ws over tls", `{"network":"ws","security":"tls","wsSettings":{"path":"/"}}`, `{"clients":[],"decryption":"none"}`}, } for i, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -125,7 +130,7 @@ func TestGetXrayConfig_OmitsVisionFlowOnIneligibleStream(t *testing.T) { {Email: "vision@x", ID: "44444444-4444-4444-4444-444444444444", Enable: true, Flow: "xtls-rprx-vision"}, }) if err := database.GetDB().Model(&model.Inbound{}).Where("tag = ?", tag). - Update("stream_settings", tc.stream).Error; err != nil { + Updates(map[string]any{"stream_settings": tc.stream, "settings": tc.settings}).Error; err != nil { t.Fatalf("set stream: %v", err) } value, _ := emittedClients(t, tag) @@ -141,7 +146,7 @@ func TestGetXrayConfig_OmitsVisionFlowOnIneligibleStream(t *testing.T) { } } -// xray-core runs Vision over TLS/REALITY on RAW, or over VLESS encryption on any transport. +// xray-core runs Vision over TLS/REALITY on RAW, or over VLESS encryption on any transport but XDRIVE. func TestGetXrayConfig_KeepsVisionFlowWhereTheCoreRunsIt(t *testing.T) { const vlessenc = `{"clients":[],"decryption":"mlkem768x25519plus.native.600s.WB3OymHLL_DJ1jdHiqcMC-u84TgvfNFlgbZIJE-NtUQ"}` cases := []struct { @@ -152,7 +157,6 @@ func TestGetXrayConfig_KeepsVisionFlowWhereTheCoreRunsIt(t *testing.T) { {"tcp reality", `{"network":"tcp","security":"reality","realitySettings":{}}`, `{"clients":[],"decryption":"none"}`}, {"raw vlessenc", `{"network":"tcp","security":"none"}`, vlessenc}, {"ws vlessenc", `{"network":"ws","security":"none","wsSettings":{"path":"/"}}`, vlessenc}, - {"xdrive vlessenc", `{"network":"xdrive","security":"none","xdriveSettings":{"service":"local","remoteFolder":"/tmp/shared"}}`, vlessenc}, } for i, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/internal/web/service/xray_setting.go b/internal/web/service/xray_setting.go index eba12c788..7d41f1a9d 100644 --- a/internal/web/service/xray_setting.go +++ b/internal/web/service/xray_setting.go @@ -53,6 +53,9 @@ func (s *XraySettingService) CheckXrayConfig(XrayTemplateConfig string) error { if err != nil { return common.NewError("xray template config invalid:", err) } + if err := checkTemplateLuaScripts(xrayConfig); err != nil { + return err + } for _, inbound := range xrayConfig.InboundConfigs { if err := validateFinalMaskSalamander(string(inbound.StreamSettings)); err != nil { return common.NewError("xray template inbound \""+inbound.Tag+"\":", err) @@ -60,6 +63,9 @@ func (s *XraySettingService) CheckXrayConfig(XrayTemplateConfig string) error { if err := validateXDriveStorage(string(inbound.StreamSettings)); err != nil { return common.NewError("xray template inbound \""+inbound.Tag+"\":", err) } + if err := validateXDriveInbound(inbound.Protocol, string(inbound.Settings), string(inbound.StreamSettings)); err != nil { + return common.NewError("xray template inbound \""+inbound.Tag+"\":", err) + } } if len(xrayConfig.OutboundConfigs) > 0 { var outbounds []json.RawMessage @@ -111,6 +117,22 @@ func (s *XraySettingService) CheckXrayConfig(XrayTemplateConfig string) error { return nil } +// checkTemplateLuaScripts refuses the Lua scripts xray-core 26.10.17 refuses (#7136): any routing +// "script", since SaveXraySetting always keeps the api rule, and a dns "script" beside refused settings. +func checkTemplateLuaScripts(cfg *xray.Config) error { + var routing map[string]any + if json.Unmarshal(cfg.RouterConfig, &routing) == nil { + if script, _ := routing["script"].(string); script != "" { + return common.NewError(`xray template routing: "script" cannot be used, xray-core refuses it beside routing rules and the panel always keeps its api rule.`) + } + } + var dns map[string]any + if json.Unmarshal(cfg.DNSConfig, &dns) == nil && dnsScriptRefused(dns) { + return common.NewError(`xray template dns: "script" cannot be used with disableFallback, disableFallbackIfMatch, enableParallelQuery, or a server's domains, skipFallback or finalQuery.`) + } + return nil +} + // shouldSkipLegacyUnencryptedOutboundRejection lets an older running Xray // core accept an outbound that the newer embedded validator rejects solely // because it is unencrypted and targets a public address. Unknown or malformed diff --git a/internal/web/service/xray_setting_test.go b/internal/web/service/xray_setting_test.go index a1270cd1a..0e55ad92b 100644 --- a/internal/web/service/xray_setting_test.go +++ b/internal/web/service/xray_setting_test.go @@ -5,6 +5,8 @@ import ( "errors" "strings" "testing" + + "github.com/xtls/xray-core/infra/conf" ) func TestShouldSkipLegacyUnencryptedOutboundRejection(t *testing.T) { @@ -251,3 +253,60 @@ func TestEnsureStatsRouting_AcceptsInboundTagAsString(t *testing.T) { t.Fatalf("api rule with string-form inboundTag should hoist to front, got %q\nfull: %s", got, out) } } + +// xray-core 26.10.17 (#7136) refuses a routing "script" beside rules, which SaveXraySetting always +// keeps for the api inbound, and a dns "script" beside fallback switches or domain-scoped servers. +func TestCheckXrayConfig_RefusesLuaScriptsTheCoreRefuses(t *testing.T) { + const outbounds = `"outbounds":[{"protocol":"freedom","tag":"direct"}]` + tests := []struct{ name, template, want string }{ + {"routing script", `{"routing":{"script":"route.lua"},` + outbounds + `}`, `routing: "script" cannot be used`}, + {"dns script beside a domain-scoped server", `{"dns":{"script":"dns.lua","servers":[{"address":"1.1.1.1","domains":["example.com"]}]},` + outbounds + `}`, `dns: "script" cannot be used`}, + {"dns script beside parallel queries", `{"dns":{"script":"dns.lua","enableParallelQuery":true,"servers":["1.1.1.1"]},` + outbounds + `}`, `dns: "script" cannot be used`}, + {"dns script beside plain servers", `{"dns":{"script":"dns.lua","servers":["1.1.1.1",{"address":"8.8.8.8"}]},` + outbounds + `}`, ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := (&XraySettingService{}).CheckXrayConfig(tc.template) + if tc.want == "" { + if err != nil { + t.Fatalf("CheckXrayConfig = %v, want the script the core accepts kept", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("CheckXrayConfig = %v, want one containing %q", err, tc.want) + } + }) + } +} + +// A template saved under 26.10.10 can carry those scripts; the build drops them so the core still +// starts, and the vendored loader accepts the routing and dns sections it then emits. +func TestGetXrayConfig_DropsLuaScriptsTheCoreRefuses(t *testing.T) { + setupConflictDB(t) + template := `{"outbounds":[{"protocol":"freedom","tag":"direct"}], + "routing":{"domainStrategy":"AsIs","script":"route.lua","rules":[{"type":"field","inboundTag":["api"],"outboundTag":"api"}]}, + "dns":{"script":"dns.lua","servers":[{"address":"1.1.1.1","domains":["example.com"]}]}}` + if err := (&SettingService{}).saveSetting("xrayTemplateConfig", template); err != nil { + t.Fatalf("seed template: %v", err) + } + + cfg, err := (&XrayService{}).GetXrayConfig() + if err != nil { + t.Fatalf("GetXrayConfig: %v", err) + } + router := new(conf.RouterConfig) + if err := json.Unmarshal(cfg.RouterConfig, router); err != nil { + t.Fatalf("decode routing: %v", err) + } + if _, err := router.Build(); err != nil { + t.Fatalf("xray-core refuses the emitted routing: %v", err) + } + dns := new(conf.DNSConfig) + if err := json.Unmarshal(cfg.DNSConfig, dns); err != nil { + t.Fatalf("decode dns: %v", err) + } + if _, err := dns.Build(); err != nil { + t.Fatalf("xray-core refuses the emitted dns: %v", err) + } +} diff --git a/internal/xray/api.go b/internal/xray/api.go index 22a866320..0e0e68ddc 100644 --- a/internal/xray/api.go +++ b/internal/xray/api.go @@ -26,6 +26,7 @@ import ( routerService "github.com/xtls/xray-core/app/router/command" statsService "github.com/xtls/xray-core/app/stats/command" xnet "github.com/xtls/xray-core/common/net" + "github.com/xtls/xray-core/common/platform" "github.com/xtls/xray-core/common/protocol" "github.com/xtls/xray-core/common/serial" "github.com/xtls/xray-core/infra/conf" @@ -146,6 +147,7 @@ func (x *XrayAPI) AddInbound(inbound []byte) error { logger.Debug("Failed to unmarshal inbound:", err) return err } + localizeNoiseScripts(conf.StreamSetting) config, err := conf.Build() if err != nil { logger.Debug("Failed to build inbound Detur:", err) @@ -185,6 +187,7 @@ func ValidateOutboundConfig(outbound []byte) error { if err := json.Unmarshal(outbound, detour); err != nil { return err } + localizeNoiseScripts(detour.StreamSetting) built, err := detour.Build() if err != nil { return err @@ -228,6 +231,7 @@ func (x *XrayAPI) AddOutbound(outbound []byte) error { logger.Debug("Failed to unmarshal outbound:", err) return err } + localizeNoiseScripts(conf.StreamSetting) config, err := conf.Build() if err != nil { logger.Debug("Failed to build outbound detour:", err) @@ -475,6 +479,54 @@ func IsUserExistsErr(err error) bool { // config builders run inside the panel process. The xray binary resolves assets // relative to its own executable, but the panel binary lives one level above // the bin folder, so an explicit location is required. +// localizeNoiseScripts points each noise mask's relative "script" at the file the xray child loads: +// xray-core 26.10.17 (#7124) compiles it inside Build, where the panel would look beside its own binary. +func localizeNoiseScripts(stream *conf.StreamConfig) { + if stream == nil || stream.FinalMask == nil { + return + } + for i := range stream.FinalMask.Udp { + mask := &stream.FinalMask.Udp[i] + if !strings.EqualFold(mask.Type, "noise") || mask.Settings == nil { + continue + } + var settings map[string]any + if json.Unmarshal(*mask.Settings, &settings) != nil { + continue + } + script, _ := settings["script"].(string) + resolved := childLuaScriptPath(script) + if resolved == script { + continue + } + settings["script"] = resolved + if raw, err := json.Marshal(settings); err == nil { + localized := json.RawMessage(raw) + mask.Settings = &localized + } + } +} + +// childLuaScriptPath resolves a relative Lua script the way the xray child does: it shares the +// panel's working directory, then looks in its own bin folder instead of the panel's. +func childLuaScriptPath(script string) string { + if script == "" || filepath.IsAbs(script) { + return script + } + if _, err := platform.ResolveLuaFile(script); err == nil { + return script + } + binDir, err := filepath.Abs(config.GetBinFolderPath()) + if err != nil { + return script + } + inBin := filepath.Join(binDir, script) + if _, err := os.Stat(inBin); err != nil { + return script + } + return inBin +} + func ensureXrayAssetLocation() { if os.Getenv("XRAY_LOCATION_ASSET") != "" || os.Getenv("xray.location.asset") != "" { return diff --git a/internal/xray/outbound_validation_test.go b/internal/xray/outbound_validation_test.go index 3e401e265..11a506f3b 100644 --- a/internal/xray/outbound_validation_test.go +++ b/internal/xray/outbound_validation_test.go @@ -1,6 +1,8 @@ package xray import ( + "os" + "path/filepath" "strings" "testing" ) @@ -65,3 +67,22 @@ func TestValidateOutboundConfig_RejectsWireguardRemoteDNSThatIsNotAnIP(t *testin t.Fatalf("IP remoteDNS entries must stay valid, got: %v", err) } } + +// xray-core 26.10.17 compiles a noise mask's Lua script inside Build (#7124), where the panel would +// look beside its own binary; the xray child finds a relative script in its bin folder. +func TestValidateOutboundConfig_FindsANoiseScriptBesideTheXrayBinary(t *testing.T) { + bin := t.TempDir() + t.Setenv("XUI_BIN_FOLDER", bin) + t.Chdir(t.TempDir()) + if err := os.WriteFile(filepath.Join(bin, "noise.lua"), []byte("function HandleNoise(plan) end\n"), 0o600); err != nil { + t.Fatalf("write script: %v", err) + } + outbound := []byte(`{ + "protocol": "vless", + "settings": {"address": "10.0.0.1", "port": 443, "id": "b831381d-6324-4d53-ad4f-8cda48b30811", "encryption": "none"}, + "streamSettings": {"network": "tcp", "finalmask": {"udp": [{"type": "noise", "settings": {"script": "noise.lua"}}]}} + }`) + if err := ValidateOutboundConfig(outbound); err != nil { + t.Fatalf("ValidateOutboundConfig = %v, want the script beside the xray binary found", err) + } +}