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) + } +}