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) => (
-
-
-
-
-
- }
+
+
+ Resolver {ri + 1}
+ remove(resolver.name)}
+ onKeyDown={activateOnKey(() => remove(resolver.name))}
/>
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
))}
>
)}
@@ -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)
+ }
+}