feat(xray): update xray-core to v26.10.21 and adapt panel

Bump xtls/xray-core to 1b1647cd9ddf (v26.10.21) and move the three
binary pins (DockerInit.sh, both release.yml fetches) in lockstep. The
v26.10.10 release was pulled upstream, so the old pins already 404.

XDRIVE (#7182): the core now runs XDRIVE only for VLESS inside VLESS
encryption, with no flow, and an outbound only over Mux; it refuses the
whole config otherwise. The panel offers XDRIVE only for VLESS, refuses
to save an inbound without decryption, never emits a client flow on an
XDRIVE stream (config, JSON subscription, forms), and turns Mux on when
an outbound picks XDRIVE. An inbound or template outbound stored before
the upgrade that the core would refuse is left out of the generated
config with a warning, so it no longer stops every other inbound. The
service "Google Drive" is now "google-drive" (old name read as a
deprecated alias); saves, the schema and both form adapters store the
new name. XDRIVE and MASQUE gain a client xmux; both schemas keep it
instead of stripping it on an inbound save.

Lua scripts (#7124, #7136): the core refuses a routing script beside
rules or domainStrategy, and a dns script beside the fallback/parallel
switches or a domain-scoped server. The panel always keeps its api
routing rule, so a template save now refuses any routing script and a
dns script in a refused combination, and a stored one is dropped from
the generated config so the core still starts. Noise masks gain a Lua
script that the loader compiles inside Build: the in-process build
(template save check, hot add) now resolves a relative script the way
the xray child does, beside the xray binary, instead of failing it. The
mask editor gets a Script field that replaces the noise list.

XDNS (#7140): a resolver takes https:// DoH addresses with worker,
tlsConfig and quicConfig. The mask editor registered only addrs, so a
save dropped the rest; it now edits all four.

Finalmask filter (#7206): a new TCP/UDP mask screens an inbound's source
IPs. The inbound mask editor offers it with its allow and block lists;
it is a no-op on a dialer and an older client core refuses the type, so
the share link and JSON subscription drop it like the Go link already
does.
This commit is contained in:
MHSanaei
2026-10-22 17:10:11 +02:00
parent a8d0700bf2
commit 61b129d5b3
43 changed files with 1055 additions and 95 deletions
+2 -2
View File
@@ -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"
+1 -1
View File
@@ -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}"
+14 -3
View File
@@ -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.
+14 -3
View File
@@ -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 نمایش می‌دادند.
+15 -3
View File
@@ -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.
+10 -3
View File
@@ -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 — 底层套接字选项
@@ -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 && <TcpMasksList base={base} form={form} />}
{showTcp && <TcpMasksList base={base} form={form} allowFilter={side === 'server'} />}
{showUdp && (
<UdpMasksList
base={base}
@@ -341,6 +341,7 @@ export default function FinalMaskForm({
isWireguard={isWireguard}
network={network}
allowUdpHop={side === 'client'}
allowFilter={side === 'server'}
/>
)}
{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 (
<Form.List name={[...base, 'tcp']}>
@@ -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] : []),
]}
/>
</Form.Item>
@@ -493,6 +506,7 @@ function TcpMaskItem({
);
}
if (type === 'sudoku') return <SudokuSettings fieldName={fieldName} />;
if (type === 'filter') return <FilterMaskSettings fieldName={fieldName} />;
if (type === 'header-custom') {
return (
<HeaderCustomGroups
@@ -543,6 +557,41 @@ function TcpMaskItem({
);
}
const FILTER_MASK_OPTION = { value: 'filter', label: 'Filter (source IPs)' };
// xray-core 26.10.21 (#7206) screens a listener's source IPs; a blocked match wins over an allowed
// one, and an empty list leaves that side open. Links and JSON subscriptions drop it.
function FilterMaskSettings({ fieldName }: { fieldName: number }) {
return (
<>
<Form.Item
label="Allowed Source IPs"
name={[fieldName, 'settings', 'allowedSourceIPs']}
tooltip="Only these sources may connect; empty allows every source"
>
<Select
mode="tags"
style={{ width: '100%' }}
tokenSeparators={[',', ' ']}
placeholder="10.0.0.0/8, geoip:ir"
/>
</Form.Item>
<Form.Item
label="Blocked Source IPs"
name={[fieldName, 'settings', 'blockedSourceIPs']}
tooltip="These sources are dropped before any other mask runs"
>
<Select
mode="tags"
style={{ width: '100%' }}
tokenSeparators={[',', ' ']}
placeholder="203.0.113.7, geoip:cn"
/>
</Form.Item>
</>
);
}
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 <UdpHopSettings udpFieldName={fieldName} />;
}
if (type === 'filter') return <FilterMaskSettings fieldName={fieldName} />;
if (type === 'xicmp') {
return (
<>
@@ -1430,23 +1487,44 @@ function XdnsSettings({ udpFieldName }: { udpFieldName: number }) {
/>
</Form.Item>
{resolvers.map((resolver, ri) => (
<Form.Item key={resolver.key} label={`Resolver ${ri + 1}`}>
<Space.Compact block>
<Form.Item name={[resolver.name, 'addrs']} noStyle>
<Select
mode="tags"
style={{ width: '100%' }}
tokenSeparators={[',', ' ']}
placeholder="udp://8.8.8.8:53, tcp://1.1.1.1"
/>
</Form.Item>
<Button
icon={<DeleteOutlined />}
<div key={resolver.key}>
<Divider style={{ margin: 0 }}>
Resolver {ri + 1}
<DeleteOutlined
className="danger-icon"
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={() => remove(resolver.name)}
onKeyDown={activateOnKey(() => remove(resolver.name))}
/>
</Space.Compact>
</Form.Item>
</Divider>
<Form.Item label="Addresses" name={[resolver.name, 'addrs']}>
<Select
mode="tags"
style={{ width: '100%' }}
tokenSeparators={[',', ' ']}
placeholder="udp://8.8.8.8:53, https://dns.google/dns-query"
/>
</Form.Item>
<Form.Item label="Workers" name={[resolver.name, 'worker']}>
<InputNumber min={1} placeholder="32" />
</Form.Item>
<Form.Item
label="DoH TLS"
name={[resolver.name, 'tlsConfig']}
tooltip="tlsSettings for the https:// addresses, e.g. a serverName to front the domain"
>
<JsonObjectInput placeholder='{"serverName": "dns.google", "fingerprint": "chrome"}' />
</Form.Item>
<Form.Item
label="DoH QUIC"
name={[resolver.name, 'quicConfig']}
tooltip="QUIC parameters for an https:// address reached over h3"
>
<JsonObjectInput placeholder='{"congestion": "bbr"}' />
</Form.Item>
</div>
))}
</>
)}
@@ -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 (
<>
<Form.Item
label="Reset"
name={[udpFieldName, 'settings', 'reset']}
tooltip="Seconds before the noise is sent to a destination again; 0 sends it once"
rules={[{ validator: validateNoiseReset }]}
>
<Input placeholder="0 or 60-120" />
</Form.Item>
<Form.Item
label="Script"
name={[udpFieldName, 'settings', 'script']}
tooltip="A Lua file xray-core runs to build the noise, found beside the xray binary; replaces the noise list"
normalize={(value: string) => value || undefined}
>
<Input allowClear placeholder="noise.lua" />
</Form.Item>
{!script && (
<NoiseItemList
udpFieldName={udpFieldName}
form={form}
absoluteSettingsPath={absoluteSettingsPath}
/>
)}
</>
);
}
function NoiseItemList({
udpFieldName,
form,
absoluteSettingsPath,
}: {
udpFieldName: number;
form: FormInstance;
absoluteSettingsPath: (string | number)[];
}) {
const { t } = useTranslation();
return (
<>
<Form.Item
label="Reset"
name={[udpFieldName, 'settings', 'reset']}
tooltip="Seconds before the noise is sent to a destination again; 0 sends it once"
rules={[{ validator: validateNoiseReset }]}
>
<Input placeholder="0 or 60-120" />
</Form.Item>
<Form.List name={[udpFieldName, 'settings', 'noise']}>
{(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<string, unknown>;
onChange?: (next: Record<string, unknown> | undefined) => void;
placeholder?: string;
}) {
const [text, setText] = useState(() => (value ? JSON.stringify(value) : ''));
const [invalid, setInvalid] = useState(false);
@@ -1631,7 +1743,7 @@ function TransformEditor({
<Input.TextArea
value={text}
autoSize={{ minRows: 2, maxRows: 8 }}
placeholder='{"op": "concat", "args": [{"reuse": "nonce"}]}'
placeholder={placeholder}
onChange={(e) => {
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' }]}
>
<TransformEditor />
<JsonObjectInput placeholder='{"op": "concat", "args": [{"reuse": "nonce"}]}' />
</Form.Item>
)}
@@ -13,6 +13,7 @@ const SERVICE_LABEL_KEYS: Partial<Record<string, string>> = {
local: 'pages.inbounds.form.xdrive.serviceLocal',
template: 'pages.inbounds.form.xdrive.serviceTemplate',
};
const SERVICE_NAMES: Partial<Record<string, string>> = { '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() {
>
<Input placeholder={service === 'local' ? '/var/lib/xdrive' : 'xray-tunnel'} />
</FormField>
{service === 'Google Drive' &&
{service === 'google-drive' &&
GOOGLE_DRIVE_SECRETS.map((key, index) => (
<FormField
key={key}
@@ -28,6 +28,7 @@ import { canEnableSniffing } from '@/lib/xray/protocol-capabilities';
import { tlsCertUsesFiles } from '@/schemas/protocols/security/tls';
import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
import { XHttpStreamSettingsSchema, XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
import { liftLegacyXDriveService } from '@/schemas/protocols/stream/xdrive';
const XMUX_DEFAULTS = XHttpXmuxSchema.parse({});
@@ -183,6 +184,7 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
Object.keys(rawStream).length > 0 ? (rawStream as StreamSettings) : undefined;
if (streamSettings) {
healStreamNetworkKey(streamSettings as unknown as Record<string, unknown>);
liftLegacyXDriveService(streamSettings as unknown as Record<string, unknown>);
synthesizeTlsCertUseFile(streamSettings as unknown as Record<string, unknown>);
const streamRecord = streamSettings as unknown as Record<string, unknown>;
const xh = streamRecord.xhttpSettings;
+18 -1
View File
@@ -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<string, unknown> = { ...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) : '';
}
@@ -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) };
@@ -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;
@@ -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' }] : []),
]}
/>
</Form.Item>
@@ -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<string, unknown>;
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)
}
/>
</Form.Item>
@@ -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' };
@@ -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<typeof TcpMaskTypeSchema>;
export const TcpMaskSchema = z.object({
@@ -31,6 +33,7 @@ export const UdpMaskTypeSchema = z.enum([
'sudoku',
'realm',
'udphop',
'filter',
]);
export type UdpMaskType = z.infer<typeof UdpMaskTypeSchema>;
@@ -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<typeof MasqueStreamSettingsSchema>;
@@ -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<typeof XDriveServiceSchema>;
// 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<string, unknown>): void {
const xdrive = stream.xdriveSettings;
if (!xdrive || typeof xdrive !== 'object' || Array.isArray(xdrive)) return;
const settings = xdrive as Record<string, unknown>;
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<typeof XDriveStreamSettingsSchema>;
@@ -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,
},
}
+18
View File
@@ -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(
<FinalMaskField network="tcp" protocol="vless" side="client" value={value} />,
);
expect(listSelectOptions('finalmask_tcp_0_type')).not.toContain('Filter (source IPs)');
client.unmount();
renderWithProviders(
<FinalMaskField network="tcp" protocol="vless" side="server" value={value} />,
);
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.
@@ -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",
+49 -1
View File
@@ -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);
+10 -2
View File
@@ -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',
+16
View File
@@ -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({
+160 -3
View File
@@ -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) => (
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<OutboundFormModal
open={open}
outbound={outbound}
existingTags={[]}
onClose={() => {}}
onConfirm={() => {}}
/>
</ThemeProvider>
</QueryClientProvider>
);
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<string, unknown> };
const payload = onConfirm.mock.calls[0][0] as {
streamSettings: Record<string, unknown>;
mux?: Record<string, unknown>;
};
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) => (
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<OutboundFormModal
open={open}
outbound={outbound}
existingTags={[]}
onClose={() => {}}
onConfirm={onConfirm}
/>
</ThemeProvider>
</QueryClientProvider>
);
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<string, unknown> };
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: [] } }] },
+26
View File
@@ -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);
});
});
+4 -5
View File
@@ -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
+8 -10
View File
@@ -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=
+1
View File
@@ -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"])
+21
View File
@@ -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"}}}`, "")
+26 -1
View File
@@ -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
+1
View File
@@ -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) {
@@ -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,
}
+55 -2
View File
@@ -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
}
+6 -3
View File
@@ -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)
@@ -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).
+109 -1
View File
@@ -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 {
+101
View File
@@ -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"])
}
@@ -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) {
+22
View File
@@ -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
+59
View File
@@ -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)
}
}
+52
View File
@@ -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
+21
View File
@@ -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)
}
}