Merge branch 'main' into feat/farhadh/client-most-recent-session-traffic

This commit is contained in:
Sanaei 2026-10-11 16:10:06 +02:00 • committed by GitHub
commit e5cf73ffcf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
162 changed files with 5417 additions and 476 deletions

View file

@ -115,7 +115,7 @@ jobs:
cd x-ui/bin
# Download dependencies
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.9.30/"
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.10.10/"
if [ "${{ matrix.platform }}" == "amd64" ]; then
fetch ${Xray_URL}Xray-linux-64.zip
unzip Xray-linux-64.zip
@ -278,7 +278,7 @@ jobs:
cd x-ui\bin
# Download Xray for Windows
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.9.30/"
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.10.10/"
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"

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.9.30/Xray-linux-${ARCH}.zip"
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.10.10/Xray-linux-${ARCH}.zip"
unzip "Xray-linux-${ARCH}.zip"
rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
mv xray "xray-linux-${FNAME}"

View file

@ -65,6 +65,7 @@ The inbound editor accepts these protocols:
| **Dokodemo-door / Tunnel** | Port forwarding / traffic redirect. |
| **MTProto** | Telegram MTProto proxy, served by a bundled `mtg` process (not Xray). |
| **TUIC** | QUIC-based proxy protocol (v5), served by an in-process native Go server. See [TUIC](/docs/config/tuic). |
| **MASQUE** | CONNECT-IP tunnel over HTTP/3 or HTTP/2, always behind TLS; delivered to clients through the JSON subscription. See [MASQUE](/docs/config/masque). |
<Callout type="info">
Hysteria2 isn't a separate protocol internally — it's the `hysteria` protocol

View file

@ -0,0 +1,59 @@
---
title: MASQUE
description: Serve MASQUE (CONNECT-IP) inbounds in 3x-ui, connect outbounds to MASQUE servers, and reach Cloudflare WARP over MASQUE.
icon: Waypoints
---
**MASQUE** carries IP packets over HTTP/3 (or HTTP/2) with the CONNECT-IP method, so a
client gets a full layer-3 tunnel that looks like ordinary HTTPS traffic. xray-core
serves it natively; 3x-ui offers it as an inbound protocol, an outbound protocol, and the
matching `masque` transport they both ride on.
## Inbound
| Field | Description |
| ---------------- | ----------- |
| **Clients** | Each client logs in with its **email** and **password** (HTTP Basic auth). Traffic, quotas, IP limits and expiry work like any other multi-user protocol. |
| **Address pool** | Prefixes the tunnel addresses are leased from — at most one IPv4 and one IPv6 (default `10.14.0.1/24`, `fd14::1/64`). The pool size caps how many clients can be connected at once. |
| **MTU** | Tunnel MTU, 1280–65535; leave empty for the core default. |
| **Path** | Request path the server answers on (default `/.well-known/masque/ip/*/*/`). |
| **Security** | Always TLS. The ALPN picks the listeners: `h3` serves HTTP/3 on UDP, `h2` serves HTTP/2 on TCP, and both together serve both. |
<Callout type="info">
MASQUE has no share-link format, so MASQUE inbounds get no link, QR code or Clash
entry. Clients get a ready-to-import config from the **JSON subscription**: a `masque`
outbound that authenticates with the client's email and password.
</Callout>
## Outbound
Pick **masque** as the outbound protocol, then set:
| Field | Description |
| ----------------------- | ----------- |
| **Address / Port** | The MASQUE server. |
| **Remote DNS** | Optional DNS server IPs queried inside the tunnel. |
| **Host / Path** | Authority and path of the CONNECT-IP request; the path must match the server's. |
| **Username / Password** | HTTP Basic credentials — on a 3x-ui server, the client's email and password. |
| **Headers** | Extra request headers. |
| **TLS** | Required. An ALPN of `h2` alone switches to HTTP/2 over TCP; otherwise HTTP/3 is used. |
## WARP over MASQUE
The quickest way is **Xray → Outbounds → WARP → Add WARP over MASQUE outbound**: the panel
registers a separate WARP device, enrolls a MASQUE key for it and adds a ready `warp-masque`
outbound (or refreshes the existing one). Your WireGuard WARP registration is not touched.
To set it up by hand instead:
Turn on **WARP** in the masque transport to reach Cloudflare WARP through its MASQUE
endpoint instead of WireGuard. It takes a WARP registration enrolled for MASQUE:
| Field | Description |
| ----------------------- | ----------- |
| **Private key** | The enrolled ECDSA P-256 private key (PEM, or base64 DER). |
| **Endpoint public key** | Cloudflare's endpoint public key returned by the enrollment. |
| **Tunnel addresses** | The IPv4 and IPv6 addresses Cloudflare assigned to the registration. |
Host and path then default to Cloudflare's endpoint, and WARP can't be combined with a
username or password. Point the outbound at the endpoint address from your enrollment and
set the TLS server name to `consumer-masque.cloudflareclient.com`.

View file

@ -8,6 +8,7 @@
"reality",
"amneziawg",
"tuic",
"masque",
"transports",
"clients",
"subscription",

View file

@ -1,6 +1,6 @@
---
title: Transports & Security
description: Every transport 3x-ui exposes — TCP, mKCP, WebSocket, gRPC, HTTPUpgrade, XHTTP, Hysteria — with their settings, plus FinalMask obfuscation, sockopt, TLS/REALITY, XTLS-Vision, and VLESS encryption.
description: Every transport 3x-ui exposes — TCP, mKCP, WebSocket, gRPC, HTTPUpgrade, XHTTP, Hysteria, XDRIVE, MASQUE — with their settings, plus FinalMask obfuscation, sockopt, TLS/REALITY, XTLS-Vision, and VLESS encryption.
icon: Network
---
@ -23,6 +23,8 @@ network writes its own settings key on the wire (`tcpSettings`, `kcpSettings`,
| **HTTPUpgrade** | `httpupgradeSettings` | CDN-friendly HTTP/1.1 `Upgrade`; lighter than full WebSocket. |
| **XHTTP** | `xhttpSettings` | Modern stream-multiplexed HTTP transport; CDN-friendly and REALITY-capable. |
| **Hysteria** | `hysteriaSettings` | QUIC-based transport — only for the **Hysteria2** protocol. |
| **XDRIVE** | `xdriveSettings` | Tunnels the stream through files in shared cloud storage (Google Drive, a local folder or any HTTP storage API). |
| **MASQUE** | `masqueSettings` | CONNECT-IP over HTTP/3 or HTTP/2 — only for the **MASQUE** protocol. See [MASQUE](/docs/config/masque). |
<Callout type="info">
**WireGuard** and **Tunnel** (dokodemo-door) inbounds expose no transport
@ -116,6 +118,27 @@ Only valid when the protocol is **Hysteria2**.
| `udpIdleTimeout` | `60` | Seconds (2–600) before idle UDP sessions are dropped. |
| `masquerade` | — | Disguise as an HTTP/3 server: `type` `proxy`/`file`/`string` with `url`/`dir`/`content`, plus `headers` and `statusCode`. |
### XDRIVE — `xdriveSettings`
XDRIVE carries the stream as files in a folder both ends can reach: the server polls
that folder instead of accepting connections on its port. Both sides must use the
**same** service, folder and secrets, so the JSON subscription ships them to every
client; XDRIVE inbounds get no share link or Clash entry.
| Field | Default | Meaning |
| -------------- | -------------- | ----------------------------------------------------------------------- |
| `service` | `Google Drive` | `Google Drive`, `local` (a folder on disk) or `template` (any HTTP storage API). |
| `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. |
| tuning | core defaults | `segmentBytes` (512 KiB), `flushIntervalMs` (20), `pollIntervalMs`/`maxPollIntervalMs` (50/500), `eagerWindowMs` (2000), `holeTimeoutMs` (30000), `sessionTtlSeconds` (300), `concurrency` (8). |
The optional **Front address/port** (the stream-level `address`/`port`) is a domain
front the storage API is dialed through; TLS still names the API host. XDRIVE has
no TLS/REALITY layer of its own — its traffic is the storage API's HTTPS. On an
outbound, **TLS** security shapes that HTTPS connection: its SNI, uTLS fingerprint
and ALPN are what the storage API sees.
## FinalMask — late-layer obfuscation
**FinalMask** wraps traffic **after** the transport and security layers, so it can
@ -126,11 +149,16 @@ TLS. Masks are configured per direction:
stream as Minecraft protocol traffic; requires a password, with optional
hostname and player usernames).
- **UDP masks** — `salamander`, `mkcp-legacy`, `header-custom`, `xdns`, `xicmp`,
`noise`, `sudoku`, `realm`. (`mkcp-legacy` reproduces the old mKCP header
obfuscation.)
`noise`, `sudoku`, `realm`, plus the client-only `udphop`. (`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.)
- **QUIC params** — congestion control (`reno`, `bbr`, `brutal`, `force-brutal`),
Brutal up/down rates, `udpHop` (rotate the QUIC port across a range to dodge
port blocking), and receive-window tuning.
Brutal up/down rates, and receive-window tuning. On an inbound, **UDP Hop** sets
the port range advertised to clients (the share link's `mport` and the JSON
subscription's `udphop` mask); the server still listens on its own port. Since
Xray 26.9.9 a client hops only through the `udphop` mask, so the panel converts
an older client `quicParams.udpHop` to it.
FinalMask replaces the per-transport `header`/`seed` obfuscation that older Xray
builds exposed.

View file

@ -151,6 +151,20 @@ put specific rules above general ones.
}
```
### Lua scripts
Xray 26.10.10 can hand routing and DNS to Lua. **Basics → Routing Script** takes the
path of a `.lua` file that defines `HandleRoute` (absolute, or relative to the Xray
`bin` folder); **DNS → Lua Script** takes one that defines `HandleDNSQuery`, and a
DNS server's **ID** is the name such a script picks it by.
<Callout type="warn">
A routing script replaces **every** routing rule, the panel's own `api` rule
included. Send the `api` inbound to the `api` outbound in your script, or traffic
statistics and live client changes stop. Xray does not start if the file is missing
or fails to load.
</Callout>
## Balancers
A **balancer** groups outbounds by a **selector** (tag prefixes, including the

View file

@ -65,6 +65,7 @@ TLS یا REALITY) را انتخاب کنید. به [انتقال‌ها](/docs/c
| **Dokodemo-door / Tunnel** | فورواردینگ پورت / هدایت ترافیک. |
| **MTProto** | پراکسی MTProto تلگرام که توسط یک فرایند همراه `mtg` سرویس می‌شود (نه Xray). |
| **TUIC** | پروتکل پراکسی مبتنی بر QUIC نسخه ۵ که به صورت سرور بومی Go درون فرایند ارائه می‌شود. مشاهده [TUIC](/docs/config/tuic). |
| **MASQUE** | تونل CONNECT-IP روی HTTP/3 یا HTTP/2، همیشه پشت TLS؛ از طریق اشتراک JSON به کلاینت‌ها تحویل داده می‌شود. مشاهده [MASQUE](/docs/config/masque). |
<Callout type="info">
Hysteria2 در سطح داخلی یک پروتکل جداگانه نیست — همان پروتکل `hysteria` است که

View file

@ -0,0 +1,60 @@
---
title: MASQUE
description: راه‌اندازی inbound از نوع MASQUE (CONNECT-IP) در 3x-ui، اتصال outbound به سرورهای MASQUE و دسترسی به Cloudflare WARP از طریق MASQUE.
icon: Waypoints
---
**MASQUE** بسته‌های IP را با روش CONNECT-IP روی HTTP/3 (یا HTTP/2) حمل می‌کند، بنابراین
کلاینت یک تونل کامل لایهٔ ۳ می‌گیرد که شبیه ترافیک معمولی HTTPS است. xray-core آن را به‌صورت
بومی ارائه می‌کند؛ 3x-ui آن را به‌عنوان پروتکل inbound، پروتکل outbound و transport متناظر
`masque` که هر دو روی آن سوار می‌شوند در اختیار می‌گذارد.
## Inbound
| فیلد | توضیح |
| ---------------- | ----- |
| **کلاینت‌ها** | هر کلاینت با **ایمیل** و **رمز عبور** خود وارد می‌شود (HTTP Basic auth). ترافیک، سهمیه، محدودیت IP و انقضا مانند هر پروتکل چندکاربرهٔ دیگر کار می‌کند. |
| **مخزن آدرس** | پیشوندهایی که آدرس‌های تونل از آن‌ها تخصیص داده می‌شوند — حداکثر یک IPv4 و یک IPv6 (پیش‌فرض `10.14.0.1/24` و `fd14::1/64`). اندازهٔ مخزن سقف کلاینت‌های هم‌زمان متصل را تعیین می‌کند. |
| **MTU** | MTU تونل، بین 1280 تا 65535؛ برای پیش‌فرض هسته خالی بگذارید. |
| **Path** | مسیر درخواستی که سرور به آن پاسخ می‌دهد (پیش‌فرض `/.well-known/masque/ip/*/*/`). |
| **امنیت** | همیشه TLS. ALPN شنونده‌ها را تعیین می‌کند: `h3` روی UDP پروتکل HTTP/3 و `h2` روی TCP پروتکل HTTP/2 را سرو می‌کند و هر دو با هم، هر دو را. |
<Callout type="info">
MASQUE فرمت لینک اشتراک‌گذاری ندارد، بنابراین inboundهای MASQUE لینک، کد QR یا ورودی
Clash ندارند. کلاینت‌ها کانفیگ آمادهٔ import را از **اشتراک JSON** می‌گیرند: یک outbound
از نوع `masque` که با ایمیل و رمز عبور کلاینت احراز هویت می‌کند.
</Callout>
## Outbound
**masque** را به‌عنوان پروتکل outbound انتخاب کنید و سپس تنظیم کنید:
| فیلد | توضیح |
| -------------------------- | ----- |
| **آدرس / پورت** | سرور MASQUE. |
| **Remote DNS** | IPهای اختیاری سرور DNS که داخل تونل پرس‌وجو می‌شوند. |
| **Host / Path** | authority و مسیر درخواست CONNECT-IP؛ مسیر باید با مسیر سرور یکسان باشد. |
| **نام کاربری / رمز عبور** | اعتبارنامهٔ HTTP Basic — روی سرور 3x-ui، همان ایمیل و رمز عبور کلاینت. |
| **Headers** | هدرهای اضافی درخواست. |
| **TLS** | الزامی. ALPN فقط `h2` اتصال را به HTTP/2 روی TCP می‌برد؛ در غیر این صورت HTTP/3 استفاده می‌شود. |
## WARP از طریق MASQUE
ساده‌ترین راه **Xray ← Outbounds ← WARP ← افزودن outbound برای WARP روی MASQUE** است: پنل یک
دستگاه WARP جداگانه ثبت می‌کند، برای آن کلید MASQUE ثبت می‌کند و یک outbound آمادهٔ `warp-masque`
اضافه می‌کند (یا outbound موجود را تازه می‌کند). ثبت WARP وایرگارد شما دست نمی‌خورد.
برای راه‌اندازی دستی:
**WARP** را در transport مربوط به masque روشن کنید تا به‌جای WireGuard از طریق endpoint
مخصوص MASQUE به Cloudflare WARP برسید. این کار به یک ثبت‌نام WARP نیاز دارد که برای MASQUE
ثبت شده باشد:
| فیلد | توضیح |
| ------------------------ | ----- |
| **کلید خصوصی** | کلید خصوصی ECDSA P-256 ثبت‌شده (PEM یا DER با base64). |
| **کلید عمومی endpoint** | کلید عمومی endpoint کلودفلر که در ثبت‌نام برگردانده می‌شود. |
| **آدرس‌های تونل** | آدرس‌های IPv4 و IPv6 که کلودفلر به این ثبت‌نام اختصاص داده است. |
در این حالت host و path به‌طور پیش‌فرض endpoint کلودفلر هستند و WARP را نمی‌توان با نام
کاربری یا رمز عبور ترکیب کرد. outbound را به آدرس endpoint حاصل از ثبت‌نام خود هدایت کنید و
نام سرور TLS را `consumer-masque.cloudflareclient.com` قرار دهید.

View file

@ -6,6 +6,7 @@
"ssl-certificates",
"inbounds",
"reality",
"masque",
"transports",
"clients",
"subscription",

View file

@ -1,6 +1,6 @@
---
title: انتقال‌ها و امنیت
description: هر انتقالی که 3x-ui ارائه می‌دهد — TCP، mKCP، WebSocket، gRPC، HTTPUpgrade، XHTTP، Hysteria — به‌همراه تنظیماتشان، و نیز مبهم‌سازی FinalMask، sockopt، TLS/REALITY، XTLS-Vision و رمزنگاری VLESS.
description: هر انتقالی که 3x-ui ارائه می‌دهد — TCP، mKCP، WebSocket، gRPC، HTTPUpgrade، XHTTP، Hysteria، XDRIVE، MASQUE — به‌همراه تنظیماتشان، و نیز مبهم‌سازی FinalMask، sockopt، TLS/REALITY، XTLS-Vision و رمزنگاری VLESS.
icon: Network
---
@ -23,6 +23,8 @@ icon: Network
| **HTTPUpgrade** | `httpupgradeSettings` | `Upgrade` مربوط به HTTP/1.1 و سازگار با CDN؛ سبک‌تر از WebSocket کامل. |
| **XHTTP** | `xhttpSettings` | انتقال HTTP مدرن با مالتی‌پلکس جریانی؛ سازگار با CDN و توانمند برای REALITY. |
| **Hysteria** | `hysteriaSettings` | انتقال مبتنی بر QUIC — تنها برای پروتکل **Hysteria2**. |
| **XDRIVE** | `xdriveSettings` | جریان را از طریق فایل‌ها در یک فضای ذخیره‌سازی ابری مشترک (Google Drive، یک پوشهٔ محلی یا هر API ذخیره‌سازی HTTP) تونل می‌کند. |
| **MASQUE** | `masqueSettings` | CONNECT-IP روی HTTP/3 یا HTTP/2 — تنها برای پروتکل **MASQUE**. مشاهده [MASQUE](/docs/config/masque). |
<Callout type="info">
inbound‌های **WireGuard** و **Tunnel** (dokodemo-door) هیچ انتخابگر انتقالی
@ -117,6 +119,27 @@ icon: Network
| `udpIdleTimeout` | `60` | ثانیه (2–600) پیش از حذف نشست‌های بی‌کار UDP. |
| `masquerade` | — | استتار به‌عنوان یک سرور HTTP/3: `type` با مقدار `proxy`/`file`/`string` و `url`/`dir`/`content`، به‌علاوه `headers` و `statusCode`. |
### XDRIVE — `xdriveSettings`
XDRIVE جریان را به‌صورت فایل‌هایی در پوشه‌ای که هر دو طرف به آن دسترسی دارند حمل می‌کند:
سرور به‌جای پذیرش اتصال روی پورتش، آن پوشه را بررسی می‌کند. هر دو طرف باید سرویس، پوشه
و مقادیر محرمانهٔ **یکسان** داشته باشند، بنابراین اشتراک JSON آن‌ها را به همهٔ کلاینت‌ها
می‌فرستد؛ inboundهای XDRIVE لینک اشتراک‌گذاری یا ورودی Clash ندارند.
| فیلد | پیش‌فرض | معنا |
| -------------- | -------------- | ---- |
| `service` | `Google Drive` | `Google Drive`، `local` (پوشه‌ای روی دیسک) یا `template` (هر API ذخیره‌سازی HTTP). |
| `remoteFolder` | — | پوشه‌ای که فایل‌های نشست در آن قرار می‌گیرند. |
| `secrets` | — | Google Drive: دقیقاً سه مقدار ClientID، ClientSecret و RefreshToken به همین ترتیب. Template: مقادیری که با `{secret0}`، `{secret1}`، … خوانده می‌شوند. |
| `template` | — | فقط برای سرویس template: عملیات `auth`، `put`، `get`، `list` و `delete` در API ذخیره‌ساز. |
| تنظیمات دقیق | پیش‌فرض هسته | `segmentBytes` (512 KiB)، `flushIntervalMs` (20)، `pollIntervalMs`/`maxPollIntervalMs` (50/500)، `eagerWindowMs` (2000)، `holeTimeoutMs` (30000)، `sessionTtlSeconds` (300)، `concurrency` (8). |
**آدرس/پورت Front** اختیاری (کلیدهای `address`/`port` در سطح stream) یک domain front
است که API ذخیره‌ساز از طریق آن وصل می‌شود؛ نام TLS همچنان میزبان API است. XDRIVE لایهٔ
TLS/REALITY جداگانه‌ای ندارد — ترافیکش همان HTTPS خود API ذخیره‌ساز است. در اوت‌باند،
امنیت **TLS** همین اتصال HTTPS را شکل می‌دهد: API ذخیره‌ساز همان SNI، اثرانگشت uTLS و
ALPN آن را می‌بیند.
## FinalMask — مبهم‌سازی لایه پایانی
**FinalMask** ترافیک را **پس از** لایه‌های انتقال و امنیت می‌پیچد، بنابراین می‌تواند
@ -127,11 +150,16 @@ icon: Network
پروتکل Minecraft استتار می‌کند؛ گذرواژه الزامی است و نام میزبان و نام‌های بازیکن
اختیاری‌اند).
- **ماسک‌های UDP** — `salamander`، `mkcp-legacy`، `header-custom`، `xdns`، `xicmp`،
`noise`، `sudoku`، `realm`. (`mkcp-legacy` همان مبهم‌سازی قدیمی هدر mKCP را
بازتولید می‌کند.)
`noise`، `sudoku`، `realm` و `udphop` که فقط سمت کلاینت است. (`mkcp-legacy`
همان مبهم‌سازی قدیمی هدر mKCP را بازتولید می‌کند؛ `udphop` پورت مقصد — و در صورت
نیاز IP مقصد — را در فواصل زمانی یا به‌ازای هر اتصال عوض می‌کند تا مسدودسازی پورت
دور زده شود.)
- **پارامترهای QUIC** — کنترل ازدحام (`reno`، `bbr`، `brutal`، `force-brutal`)،
نرخ‌های آپلود/دانلود Brutal، `udpHop` (چرخاندن پورت QUIC در یک بازه برای دور زدن
مسدودسازی پورت)، و تنظیم پنجره دریافت.
نرخ‌های آپلود/دانلود Brutal، و تنظیم پنجره دریافت. در اینباند، **UDP Hop** بازه‌ی
پورتی را تعیین می‌کند که به کلاینت‌ها اعلام می‌شود (`mport` در لینک اشتراک و ماسک
`udphop` در اشتراک JSON)؛ خود سرور همچنان روی پورت خودش گوش می‌دهد. از Xray
26.9.9 کلاینت فقط با ماسک `udphop` پورت عوض می‌کند، برای همین پنل `quicParams.udpHop`
قدیمی کلاینت را به آن تبدیل می‌کند.
‏FinalMask جایگزین مبهم‌سازی `header`/`seed` به‌ازای هر انتقال می‌شود که بیلدهای
قدیمی‌تر Xray نمایش می‌دادند.

View file

@ -152,6 +152,20 @@ Reset یا حذف، peer مربوط به WireGuard را در حساب PIA باط
}
```
### اسکریپت‌های Lua
Xray 26.10.10 می‌تواند مسیریابی و DNS را به Lua بسپارد. **پایه ← اسکریپت مسیریابی**
مسیر یک فایل `.lua` را می‌گیرد که `HandleRoute` را تعریف می‌کند (مطلق یا نسبت به
پوشه‌ی `bin` در Xray)؛ **DNS ← اسکریپت Lua** فایلی را می‌گیرد که `HandleDNSQuery` را
تعریف می‌کند، و **شناسه**‌ی هر سرور DNS نامی است که اسکریپت با آن سرور را انتخاب می‌کند.
<Callout type="warn">
اسکریپت مسیریابی جای **همه‌ی** قوانین مسیریابی را می‌گیرد، از جمله قانون `api` خود
پنل. در اسکریپت، اینباند `api` را به اوت‌باند `api` بفرستید، وگرنه آمار ترافیک و
اعمال زنده‌ی تغییرات کلاینت‌ها متوقف می‌شود. اگر فایل وجود نداشته باشد یا بارگذاری
نشود، Xray اجرا نمی‌شود.
</Callout>
## متعادل‌کننده‌ها
یک **متعادل‌کننده** خروجی‌ها را با یک **انتخاب‌گر** (پیشوندهای برچسب، از جمله مجموعه‌های

View file

@ -66,6 +66,7 @@ icon: ArrowDownToLine
| **Dokodemo-door / Tunnel** | Перенаправление портов / перенаправление трафика. |
| **MTProto** | Прокси Telegram MTProto, обслуживаемый встроенным процессом `mtg` (не Xray). |
| **TUIC** | Протокол проксирования на базе QUIC (v5), обслуживаемый встроенным сервером на Go. См. [TUIC](/docs/config/tuic). |
| **MASQUE** | Туннель CONNECT-IP поверх HTTP/3 или HTTP/2, всегда за TLS; клиенты получают его через JSON-подписку. См. [MASQUE](/docs/config/masque). |
<Callout type="info">
Hysteria2 внутренне не является отдельным протоколом — это протокол `hysteria`

View file

@ -0,0 +1,59 @@
---
title: MASQUE
description: Настройка инбаундов MASQUE (CONNECT-IP) в 3x-ui, подключение аутбаундов к серверам MASQUE и доступ к Cloudflare WARP через MASQUE.
icon: Waypoints
---
**MASQUE** передаёт IP-пакеты поверх HTTP/3 (или HTTP/2) методом CONNECT-IP, поэтому клиент
получает полноценный туннель третьего уровня, похожий на обычный HTTPS-трафик. xray-core
поддерживает его нативно; 3x-ui предлагает его как протокол инбаунда, протокол аутбаунда и
соответствующий транспорт `masque`, на котором работают оба.
## Инбаунд
| Поле | Описание |
| ---------------- | -------- |
| **Клиенты** | Каждый клиент входит со своими **email** и **паролем** (HTTP Basic auth). Трафик, квоты, лимиты IP и срок действия работают как в любом другом многопользовательском протоколе. |
| **Пул адресов** | Префиксы, из которых выдаются адреса туннелей, — не более одного IPv4 и одного IPv6 (по умолчанию `10.14.0.1/24`, `fd14::1/64`). Размер пула ограничивает число одновременно подключённых клиентов. |
| **MTU** | MTU туннеля, 1280–65535; оставьте пустым для значения ядра по умолчанию. |
| **Path** | Путь запроса, на который отвечает сервер (по умолчанию `/.well-known/masque/ip/*/*/`). |
| **Безопасность** | Всегда TLS. ALPN определяет слушатели: `h3` обслуживает HTTP/3 по UDP, `h2` — HTTP/2 по TCP, вместе — оба. |
<Callout type="info">
У MASQUE нет формата ссылок, поэтому для инбаундов MASQUE не создаются ссылка, QR-код и
запись Clash. Клиенты получают готовую к импорту конфигурацию из **JSON-подписки**:
аутбаунд `masque`, который аутентифицируется email и паролем клиента.
</Callout>
## Аутбаунд
Выберите протокол аутбаунда **masque** и задайте:
| Поле | Описание |
| -------------------------- | -------- |
| **Адрес / Порт** | Сервер MASQUE. |
| **Remote DNS** | Необязательные IP DNS-серверов, к которым идут запросы внутри туннеля. |
| **Host / Path** | Authority и путь запроса CONNECT-IP; путь должен совпадать с путём сервера. |
| **Имя пользователя / Пароль** | Учётные данные HTTP Basic — на сервере 3x-ui это email и пароль клиента. |
| **Headers** | Дополнительные заголовки запроса. |
| **TLS** | Обязательно. ALPN только с `h2` переключает на HTTP/2 по TCP; иначе используется HTTP/3. |
## WARP через MASQUE
Проще всего: **Xray → Аутбаунды → WARP → Добавить аутбаунд WARP через MASQUE** — панель
регистрирует отдельное устройство WARP, привязывает к нему ключ MASQUE и добавляет готовый
аутбаунд `warp-masque` (или обновляет существующий). Регистрация WARP для WireGuard не меняется.
Для ручной настройки:
Включите **WARP** в транспорте masque, чтобы подключаться к Cloudflare WARP через его
MASQUE-эндпоинт вместо WireGuard. Для этого нужна регистрация WARP, привязанная к MASQUE:
| Поле | Описание |
| -------------------------- | -------- |
| **Закрытый ключ** | Зарегистрированный закрытый ключ ECDSA P-256 (PEM или DER в base64). |
| **Открытый ключ эндпоинта** | Открытый ключ эндпоинта Cloudflare, возвращённый при регистрации. |
| **Адреса туннеля** | Адреса IPv4 и IPv6, которые Cloudflare назначил регистрации. |
В этом случае host и path по умолчанию указывают на эндпоинт Cloudflare, а WARP нельзя
сочетать с именем пользователя или паролем. Направьте аутбаунд на адрес эндпоинта из вашей
регистрации и укажите имя сервера TLS `consumer-masque.cloudflareclient.com`.

View file

@ -7,6 +7,7 @@
"inbounds",
"reality",
"tuic",
"masque",
"transports",
"clients",
"subscription",

View file

@ -1,6 +1,6 @@
---
title: Транспорты и безопасность
description: Все транспорты, которые предоставляет 3x-ui — TCP, mKCP, WebSocket, gRPC, HTTPUpgrade, XHTTP, Hysteria — с их настройками, а также обфускация FinalMask, sockopt, TLS/REALITY, XTLS-Vision и шифрование VLESS.
description: Все транспорты, которые предоставляет 3x-ui — TCP, mKCP, WebSocket, gRPC, HTTPUpgrade, XHTTP, Hysteria, XDRIVE, MASQUE — с их настройками, а также обфускация FinalMask, sockopt, TLS/REALITY, XTLS-Vision и шифрование VLESS.
icon: Network
---
@ -25,6 +25,8 @@ network записывает свой собственный ключ настр
| **HTTPUpgrade** | `httpupgradeSettings` | Дружественный к CDN апгрейд HTTP/1.1 `Upgrade`; легче полноценного WebSocket. |
| **XHTTP** | `xhttpSettings` | Современный HTTP-транспорт с мультиплексированием потоков; дружественный к CDN и совместимый с REALITY. |
| **Hysteria** | `hysteriaSettings` | Транспорт на базе QUIC — только для протокола **Hysteria2**. |
| **XDRIVE** | `xdriveSettings` | Туннелирует поток через файлы в общем облачном хранилище (Google Drive, локальная папка или любой HTTP API хранилища). |
| **MASQUE** | `masqueSettings` | CONNECT-IP поверх HTTP/3 или HTTP/2 — только для протокола **MASQUE**. См. [MASQUE](/docs/config/masque). |
<Callout type="info">
Inbound-соединения **WireGuard** и **Tunnel** (dokodemo-door) не предоставляют
@ -119,6 +121,27 @@ HTTPUpgrade — это одноразовый HTTP/1.1 `Upgrade` без фрей
| `udpIdleTimeout` | `60` | Секунды (2–600) до сброса простаивающих UDP-сессий. |
| `masquerade` | — | Маскировка под HTTP/3-сервер: `type` `proxy`/`file`/`string` с `url`/`dir`/`content`, а также `headers` и `statusCode`. |
### XDRIVE — `xdriveSettings`
XDRIVE передаёт поток файлами в папке, доступной обеим сторонам: сервер опрашивает эту
папку, а не принимает соединения на своём порту. Обе стороны должны использовать
**одинаковые** сервис, папку и секреты, поэтому JSON-подписка передаёт их каждому
клиенту; для XDRIVE-инбаундов не создаются ссылки и записи Clash.
| Поле | По умолчанию | Значение |
| -------------- | -------------- | -------- |
| `service` | `Google Drive` | `Google Drive`, `local` (папка на диске) или `template` (любой HTTP API хранилища). |
| `remoteFolder` | — | Папка с файлами сессий. |
| `secrets` | — | Google Drive: ровно три значения — ClientID, ClientSecret, RefreshToken, в этом порядке. Template: значения, доступные как `{secret0}`, `{secret1}`, …. |
| `template` | — | Только для template: операции `auth`, `put`, `get`, `list` и `delete` API хранилища. |
| тонкая настройка | значения ядра | `segmentBytes` (512 KiB), `flushIntervalMs` (20), `pollIntervalMs`/`maxPollIntervalMs` (50/500), `eagerWindowMs` (2000), `holeTimeoutMs` (30000), `sessionTtlSeconds` (300), `concurrency` (8). |
Необязательные **адрес/порт фронтинга** (`address`/`port` на уровне stream) — домен-фронт,
через который идёт подключение к API хранилища; в TLS по-прежнему указывается хост API.
У XDRIVE нет собственного уровня TLS/REALITY — его трафик это HTTPS самого API хранилища.
На исходящем безопасность **TLS** задаёт это HTTPS-подключение: API хранилища видит его
SNI, отпечаток uTLS и ALPN.
## FinalMask — обфускация на позднем уровне
**FinalMask** оборачивает трафик **после** уровней транспорта и безопасности,
@ -129,11 +152,16 @@ HTTPUpgrade — это одноразовый HTTP/1.1 `Upgrade` без фрей
под трафик протокола Minecraft; требуется пароль, имя хоста и имена игроков
опциональны).
- **UDP-маски** — `salamander`, `mkcp-legacy`, `header-custom`, `xdns`, `xicmp`,
`noise`, `sudoku`, `realm`. (`mkcp-legacy` воспроизводит старую обфускацию
заголовка mKCP.)
`noise`, `sudoku`, `realm`, а также только клиентская `udphop`. (`mkcp-legacy`
воспроизводит старую обфускацию заголовка mKCP; `udphop` меняет удалённый порт —
и при желании удалённый IP — по интервалу или для каждого соединения, чтобы
обходить блокировку портов.)
- **Параметры QUIC** — управление перегрузкой (`reno`, `bbr`, `brutal`,
`force-brutal`), скорости отдачи/приёма Brutal, `udpHop` (ротация QUIC-порта в
пределах диапазона для обхода блокировки портов) и настройка окна приёма.
`force-brutal`), скорости отдачи/приёма Brutal и настройка окна приёма. На
инбаунде **UDP Hop** задаёт диапазон портов, который объявляется клиентам
(`mport` в ссылке и маска `udphop` в JSON-подписке); сам сервер слушает свой
порт. Начиная с Xray 26.9.9 клиент меняет порт только через маску `udphop`,
поэтому панель преобразует старый клиентский `quicParams.udpHop` в неё.
FinalMask заменяет обфускацию `header`/`seed` на уровне отдельного транспорта,
которую предоставляли старые сборки Xray.

View file

@ -165,6 +165,20 @@ hostname получает уникальный тег `nord-<hostname>` и не
}
```
### Lua-скрипты
Xray 26.10.10 умеет передавать маршрутизацию и DNS в Lua. **Основное → Скрипт
маршрутизации** принимает путь к файлу `.lua` с функцией `HandleRoute` (абсолютный
или относительно папки `bin` Xray); **DNS → Lua-скрипт** — файл с функцией
`HandleDNSQuery`, а **ID** DNS-сервера — имя, по которому такой скрипт его выбирает.
<Callout type="warn">
Скрипт маршрутизации заменяет **все** правила маршрутизации, включая собственное
правило `api` панели. Направляйте в скрипте входящий `api` на исходящий `api`,
иначе остановятся статистика трафика и применение изменений клиентов на лету. Xray не
запустится, если файл отсутствует или не загружается.
</Callout>
## Балансировщики
**Балансировщик** группирует исходящие соединения по **селектору** (префиксы

View file

@ -62,6 +62,7 @@ icon: ArrowDownToLine
| **Dokodemo-door / Tunnel** | 端口转发 / 流量重定向。 |
| **MTProto** | Telegram MTProto 代理,由内置的 `mtg` 进程提供(而非 Xray)。 |
| **TUIC** | 基于 QUIC 的代理协议(v5),由进程内原生 Go 服务器提供。参见 [TUIC](/docs/config/tuic)。 |
| **MASQUE** | 基于 HTTP/3 或 HTTP/2 的 CONNECT-IP 隧道,始终使用 TLS;通过 JSON 订阅下发给客户端。参见 [MASQUE](/docs/config/masque)。 |
<Callout type="info">
在内部,Hysteria2 并不是一个独立的协议——它是把传输版本设为 2 的 `hysteria`

View file

@ -0,0 +1,55 @@
---
title: MASQUE
description: 在 3x-ui 中提供 MASQUE(CONNECT-IP)入站、将出站连接到 MASQUE 服务器,以及通过 MASQUE 连接 Cloudflare WARP。
icon: Waypoints
---
**MASQUE** 使用 CONNECT-IP 方法通过 HTTP/3(或 HTTP/2)承载 IP 数据包,客户端因此获得一条看起来与普通
HTTPS 流量无异的完整三层隧道。xray-core 原生支持它;3x-ui 将其作为入站协议、出站协议以及二者共用的 `masque`
传输方式提供。
## 入站
| 字段 | 说明 |
| ------------ | ---- |
| **客户端** | 每个客户端使用自己的**邮箱**和**密码**登录(HTTP Basic 认证)。流量、配额、IP 限制和到期时间与其他多用户协议相同。 |
| **地址池** | 隧道地址从这些前缀中分配——最多一个 IPv4 和一个 IPv6(默认 `10.14.0.1/24`、`fd14::1/64`)。地址池大小决定同时在线的客户端上限。 |
| **MTU** | 隧道 MTU,1280–65535;留空则使用内核默认值。 |
| **Path** | 服务端响应的请求路径(默认 `/.well-known/masque/ip/*/*/`)。 |
| **安全** | 始终为 TLS。ALPN 决定监听方式:`h3` 在 UDP 上提供 HTTP/3,`h2` 在 TCP 上提供 HTTP/2,两者同时设置则都提供。 |
<Callout type="info">
MASQUE 没有分享链接格式,因此 MASQUE 入站不生成链接、二维码或 Clash 条目。客户端通过 **JSON 订阅**获取可直接导入的配置:
一个使用该客户端邮箱和密码认证的 `masque` 出站。
</Callout>
## 出站
选择 **masque** 作为出站协议,然后设置:
| 字段 | 说明 |
| ------------------- | ---- |
| **地址 / 端口** | MASQUE 服务器。 |
| **Remote DNS** | 可选,在隧道内查询的 DNS 服务器 IP。 |
| **Host / Path** | CONNECT-IP 请求的 authority 和路径;路径必须与服务端一致。 |
| **用户名 / 密码** | HTTP Basic 凭据——在 3x-ui 服务端上即客户端的邮箱和密码。 |
| **Headers** | 额外的请求头。 |
| **TLS** | 必需。ALPN 仅为 `h2` 时改用 TCP 上的 HTTP/2;否则使用 HTTP/3。 |
## 通过 MASQUE 使用 WARP
最简单的方式是 **Xray → 出站 → WARP → 添加 WARP over MASQUE 出站**:面板会单独注册一个 WARP 设备、为其注册 MASQUE 密钥,
并添加一个可直接使用的 `warp-masque` 出站(若已存在则更新)。你的 WireGuard WARP 注册不受影响。
如需手动设置:
在 masque 传输中开启 **WARP**,即可通过 Cloudflare 的 MASQUE 端点而非 WireGuard 连接 WARP。这需要一个已为
MASQUE 注册的 WARP 账户:
| 字段 | 说明 |
| ---------------- | ---- |
| **私钥** | 已注册的 ECDSA P-256 私钥(PEM 或 base64 编码的 DER)。 |
| **端点公钥** | 注册时返回的 Cloudflare 端点公钥。 |
| **隧道地址** | Cloudflare 为该注册分配的 IPv4 和 IPv6 地址。 |
此时 host 和 path 默认指向 Cloudflare 端点,且 WARP 不能与用户名或密码同时使用。请将出站指向注册所得的端点地址,
并将 TLS 服务器名称设为 `consumer-masque.cloudflareclient.com`。

View file

@ -6,6 +6,7 @@
"ssl-certificates",
"inbounds",
"reality",
"masque",
"transports",
"clients",
"subscription",

View file

@ -1,6 +1,6 @@
---
title: 传输方式与安全层
description: 3x-ui 提供的每一种传输方式——TCP、mKCP、WebSocket、gRPC、HTTPUpgrade、XHTTP、Hysteria——及其设置项,外加 FinalMask 混淆、sockopt、TLS/REALITY、XTLS-Vision 以及 VLESS 加密。
description: 3x-ui 提供的每一种传输方式——TCP、mKCP、WebSocket、gRPC、HTTPUpgrade、XHTTP、Hysteria、XDRIVE、MASQUE——及其设置项,外加 FinalMask 混淆、sockopt、TLS/REALITY、XTLS-Vision 以及 VLESS 加密。
icon: Network
---
@ -22,6 +22,8 @@ icon: Network
| **HTTPUpgrade** | `httpupgradeSettings` | 对 CDN 友好的 HTTP/1.1 `Upgrade`;比完整的 WebSocket 更轻量。 |
| **XHTTP** | `xhttpSettings` | 现代的流多路复用 HTTP 传输;对 CDN 友好且支持 REALITY。 |
| **Hysteria** | `hysteriaSettings` | 基于 QUIC 的传输——仅用于 **Hysteria2** 协议。 |
| **XDRIVE** | `xdriveSettings` | 通过共享云存储(Google Drive、本地文件夹或任意 HTTP 存储 API)中的文件传输数据流。 |
| **MASQUE** | `masqueSettings` | 基于 HTTP/3 或 HTTP/2 的 CONNECT-IP——仅用于 **MASQUE** 协议。参见 [MASQUE](/docs/config/masque)。 |
<Callout type="info">
**WireGuard** 和 **Tunnel**(dokodemo-door)入站不提供传输方式选择器——它们的传输流
@ -112,6 +114,23 @@ Session-ID 字段(`sessionIDPlacement`、`sessionIDKey`、`sessionIDTable`、
| `udpIdleTimeout` | `60` | 空闲 UDP 会话被丢弃前的秒数(2–600)。 |
| `masquerade` | — | 伪装成一个 HTTP/3 服务器:`type` 为 `proxy`/`file`/`string`,配合 `url`/`dir`/`content`,外加 `headers` 与 `statusCode`。 |
### XDRIVE — `xdriveSettings`
XDRIVE 把数据流作为文件存放在双方都能访问的文件夹中:服务端轮询该文件夹,而不是在端口上接受连接。
双方必须使用**相同**的服务、文件夹和密钥,因此 JSON 订阅会把它们下发给每个客户端;XDRIVE 入站不生成分享链接或 Clash 条目。
| 字段 | 默认值 | 含义 |
| -------------- | -------------- | ---- |
| `service` | `Google Drive` | `Google Drive`、`local`(磁盘上的文件夹)或 `template`(任意 HTTP 存储 API)。 |
| `remoteFolder` | — | 存放会话文件的文件夹。 |
| `secrets` | — | Google Drive:恰好三项,依次为 ClientID、ClientSecret、RefreshToken。Template:以 `{secret0}`、`{secret1}`… 引用的值。 |
| `template` | — | 仅用于 template 服务:存储 API 的 `auth`、`put`、`get`、`list` 和 `delete` 操作。 |
| 调优参数 | 内核默认值 | `segmentBytes`(512 KiB)、`flushIntervalMs`(20)、`pollIntervalMs`/`maxPollIntervalMs`(50/500)、`eagerWindowMs`(2000)、`holeTimeoutMs`(30000)、`sessionTtlSeconds`(300)、`concurrency`(8)。 |
可选的**前置地址/端口**(stream 级的 `address`/`port`)是连接存储 API 时使用的域前置;TLS 中仍使用 API 主机名。
XDRIVE 没有自己的 TLS/REALITY 层——其流量就是存储 API 自身的 HTTPS。在出站上,**TLS**
安全设置决定这条 HTTPS 连接:存储 API 看到的就是其中的 SNI、uTLS 指纹和 ALPN。
## FinalMask — 末层混淆
**FinalMask** 在传输方式和安全层**之后**包裹流量,因此它既能伪装那些承载不了 TLS 的
@ -120,9 +139,14 @@ Session-ID 字段(`sessionIDPlacement`、`sessionIDKey`、`sessionIDTable`、
- **TCP 掩码** — `fragment`、`sudoku`、`header-custom`、`xmc`(把流量伪装成
Minecraft 协议;密码必填,主机名和玩家用户名可选)。
- **UDP 掩码** — `salamander`、`mkcp-legacy`、`header-custom`、`xdns`、`xicmp`、
`noise`、`sudoku`、`realm`。(`mkcp-legacy` 重现旧版 mKCP 的头部混淆。)
`noise`、`sudoku`、`realm`,以及仅限客户端的 `udphop`。(`mkcp-legacy` 重现旧版
mKCP 的头部混淆;`udphop` 按时间间隔或按连接轮换远端端口——也可轮换远端 IP——以规避
端口封锁。)
- **QUIC 参数** — 拥塞控制(`reno`、`bbr`、`brutal`、`force-brutal`)、Brutal 上/下行
速率、`udpHop`(在一个范围内轮换 QUIC 端口以规避端口封锁)以及接收窗口调优。
速率以及接收窗口调优。在入站上,**UDP Hop** 设置向客户端公布的端口范围(分享链接的
`mport` 和 JSON 订阅中的 `udphop` 掩码);服务器本身仍只监听自己的端口。自 Xray
26.9.9 起客户端只通过 `udphop` 掩码跳端口,因此面板会把客户端旧的
`quicParams.udpHop` 转换为该掩码。
FinalMask 取代了旧版 Xray 构建中暴露的、按每种传输各自实现的 `header`/`seed` 混淆。

View file

@ -140,6 +140,18 @@ Xray 配置中——而不会改动你已保存的模板。这是订阅一*池*
}
```
### Lua 脚本
Xray 26.10.10 可以把路由和 DNS 交给 Lua 处理。**基础配置 → 路由脚本** 填写定义了
`HandleRoute` 的 `.lua` 文件路径(绝对路径或相对于 Xray `bin` 目录);**DNS → Lua
脚本** 填写定义了 `HandleDNSQuery` 的文件,DNS 服务器的 **ID** 就是脚本选择它时使用的名称。
<Callout type="warn">
路由脚本会取代**全部**路由规则,包括面板自带的 `api` 规则。请在脚本中把 `api` 入站
发往 `api` 出站,否则流量统计和客户端变更的实时生效都会停止。文件不存在或加载失败时
Xray 无法启动。
</Callout>
## 均衡器
**均衡器**通过一个**选择器**(标签前缀,包括来自出站订阅的通配符池)将出站分组,

View file

@ -3134,7 +3134,8 @@
"tun",
"mtproto",
"amneziawg",
"tuic"
"tuic",
"masque"
],
"example": "vless",
"type": "string"
@ -13674,7 +13675,7 @@
"name": "action",
"in": "path",
"required": true,
"description": "data — return Warp stats. del — delete Warp data. config — return current config. reg — register (sends keys). changeIp — rotate the endpoint. license — set a Warp+ key. interval — set automatic rotation in hours.",
"description": "data — return Warp stats. del — delete Warp data. config — return current config. reg — register (sends keys). regMasque — register a separate device enrolled for MASQUE and return its warp key pair, addresses and endpoint. changeIp — rotate the endpoint. license — set a Warp+ key. interval — set automatic rotation in hours.",
"schema": {
"type": "string"
}

View file

@ -3134,7 +3134,8 @@
"tun",
"mtproto",
"amneziawg",
"tuic"
"tuic",
"masque"
],
"example": "vless",
"type": "string"
@ -13674,7 +13675,7 @@
"name": "action",
"in": "path",
"required": true,
"description": "data — return Warp stats. del — delete Warp data. config — return current config. reg — register (sends keys). changeIp — rotate the endpoint. license — set a Warp+ key. interval — set automatic rotation in hours.",
"description": "data — return Warp stats. del — delete Warp data. config — return current config. reg — register (sends keys). regMasque — register a separate device enrolled for MASQUE and return its warp key pair, addresses and endpoint. changeIp — rotate the endpoint. license — set a Warp+ key. interval — set automatic rotation in hours.",
"schema": {
"type": "string"
}

View file

@ -363,6 +363,7 @@ export default function CommandPalette() {
'shadowsocks',
'wireguard',
'hysteria',
'masque',
],
icon: <ImportOutlined />,
},

View file

@ -3108,7 +3108,8 @@ export const SCHEMAS: Record<string, unknown> = {
"tun",
"mtproto",
"amneziawg",
"tuic"
"tuic",
"masque"
],
"example": "vless",
"type": "string"

View file

@ -760,7 +760,7 @@ export const InboundSchema = z.object({
nodeId: z.number().int().nullable().optional(),
originNodeGuid: z.string().optional(),
port: z.number().int().min(0).max(65535),
protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'http', 'mixed', 'tunnel', 'tun', 'mtproto', 'amneziawg', 'tuic']),
protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'http', 'mixed', 'tunnel', 'tun', 'mtproto', 'amneziawg', 'tuic', 'masque']),
remark: z.string(),
settings: z.unknown(),
shareAddr: z.string(),

View file

@ -10,6 +10,7 @@ interface FinalMaskFieldProps {
network: string;
protocol: string;
showAll?: boolean;
side?: 'server' | 'client';
}
const EMPTY: FinalMaskStreamSettings = { tcp: [], udp: [] };
@ -20,6 +21,7 @@ export default function FinalMaskField({
network,
protocol,
showAll,
side,
}: FinalMaskFieldProps) {
const [form] = Form.useForm();
const [initial] = useState(() => value ?? EMPTY);
@ -55,6 +57,7 @@ export default function FinalMaskField({
protocol={protocol}
form={form}
showAll={showAll}
side={side}
/>
</Form>
);

View file

@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import {
AutoComplete,
Button,
@ -9,6 +9,7 @@ import {
Select,
Space,
Switch,
Typography,
} from 'antd';
import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
@ -18,6 +19,7 @@ import type { NamePath } from 'antd/es/form/interface';
import { RandomUtil } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import { OutboundProtocols, UTLS_FINGERPRINT } from '@/schemas/primitives';
import { upgradeLegacyUdpHop } from '@/lib/xray/udphop-mask';
import { upgradeLegacyXdnsMasks, XDNS_LEGACY_EDNS0 } from '@/lib/xray/xdns-mask';
const UTLS_FINGERPRINT_OPTIONS = Object.values(UTLS_FINGERPRINT).map((value) => ({
@ -34,9 +36,12 @@ 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.
side?: 'server' | 'client';
}
const TCP_NETWORKS = ['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp'];
const TCP_NETWORKS = ['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp', 'xdrive'];
const DEFAULT_GECKO_PACKET_SIZE = { min: 512, max: 1200 };
// Xray-core caps the Gecko output packet size at its internal buffer (2048)
// and needs 1 <= min <= max; mirror those bounds so the panel rejects what
@ -94,14 +99,7 @@ function defaultTcpMaskSettings(type: string): Record<string, unknown> {
// a single length entry reproduces the legacy single-range behavior.
return { packets: '1-3', lengths: ['100-200'], delays: [], maxSplit: '' };
case 'sudoku':
return {
password: '',
ascii: '',
customTable: '',
customTables: [],
paddingMin: 0,
paddingMax: 0,
};
return defaultSudokuSettings();
case 'header-custom':
return { clients: [], servers: [] };
case 'xmc':
@ -115,6 +113,17 @@ function defaultTcpMaskSettings(type: string): Record<string, unknown> {
}
}
function defaultSudokuSettings(): Record<string, unknown> {
return {
password: '',
ascii: '',
customTable: '',
customTables: [],
paddingMin: 0,
paddingMax: 0,
};
}
function defaultXmcProfile(): Record<string, unknown> {
return { username: '', uuid: '', texturesValue: '', texturesSignature: '' };
}
@ -194,6 +203,10 @@ function defaultUdpMaskSettings(type: string): Record<string, unknown> {
return { client: [], server: [] };
case 'noise':
return { reset: 0, noise: [] };
case 'sudoku':
return defaultSudokuSettings();
case 'udphop':
return { mode: 'intervalRemote', interval: '5-10', remotePorts: '' };
default:
return {};
}
@ -242,11 +255,12 @@ export default function FinalMaskForm({
protocol,
form,
showAll = false,
side = 'client',
}: FinalMaskFormProps) {
const base = asPath(name);
// Migrate legacy mask shapes once on mount so configs saved before #6334 (fragment
// ranges), #6487 (xmc profiles) and #6718 (xdns objects) render in the list UI.
// ranges), #6487 (xmc profiles) and #7090 (xdns names/addrs) render in the list UI.
const migratedRef = useRef(false);
useEffect(() => {
if (migratedRef.current) return;
@ -273,6 +287,13 @@ export default function FinalMaskForm({
const { next, changed } = upgradeLegacyXdnsMasks(udp);
if (changed) form.setFieldValue([...base, 'udp'], next);
}
if (side === 'client') {
const finalmask = form.getFieldValue(base) as Record<string, unknown> | undefined;
if (finalmask && typeof finalmask === 'object') {
const { next, changed } = upgradeLegacyUdpHop(finalmask);
if (changed) form.setFieldValue(base, next);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@ -282,7 +303,13 @@ export default function FinalMaskForm({
// wrap anything even though the leftover network value may be 'tcp'.
const isWireguard = protocol === 'wireguard';
const showTcp = showAll || (!isWireguard && TCP_NETWORKS.includes(network));
const showUdp = showAll || isHysteria || isWireguard || network === 'kcp';
// xhttp's HTTP/3 dial runs the UDP masks too, which is where a client's udphop lives.
const showUdp =
showAll ||
isHysteria ||
isWireguard ||
network === 'kcp' ||
(side === 'client' && network === 'xhttp');
const showQuic = showAll || isHysteria || network === 'xhttp';
const quicParams = Form.useWatch([...base, 'quicParams'], { form, preserve: true });
const hasQuicParams = quicParams != null;
@ -299,6 +326,7 @@ export default function FinalMaskForm({
isHysteria={isHysteria}
isWireguard={isWireguard}
network={network}
allowUdpHop={side === 'client'}
/>
)}
{showQuic && (
@ -311,7 +339,13 @@ export default function FinalMaskForm({
}}
/>
</Form.Item>
{hasQuicParams && <QuicParamsForm base={[...base, 'quicParams']} form={form} />}
{hasQuicParams && (
<QuicParamsForm
base={[...base, 'quicParams']}
form={form}
showUdpHop={side === 'server'}
/>
)}
</>
)}
</>
@ -444,30 +478,7 @@ function TcpMaskItem({
</>
);
}
if (type === 'sudoku') {
return (
<>
<Form.Item label="Password" name={[fieldName, 'settings', 'password']}>
<Input />
</Form.Item>
<Form.Item label="ASCII" name={[fieldName, 'settings', 'ascii']}>
<Input />
</Form.Item>
<Form.Item label="Custom Table" name={[fieldName, 'settings', 'customTable']}>
<Input />
</Form.Item>
<Form.Item label="Custom Tables" name={[fieldName, 'settings', 'customTables']}>
<Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} />
</Form.Item>
<Form.Item label="Padding Min" name={[fieldName, 'settings', 'paddingMin']}>
<InputNumber min={0} />
</Form.Item>
<Form.Item label="Padding Max" name={[fieldName, 'settings', 'paddingMax']}>
<InputNumber min={0} />
</Form.Item>
</>
);
}
if (type === 'sudoku') return <SudokuSettings fieldName={fieldName} />;
if (type === 'header-custom') {
return (
<HeaderCustomGroups
@ -518,6 +529,31 @@ function TcpMaskItem({
);
}
function SudokuSettings({ fieldName }: { fieldName: number }) {
return (
<>
<Form.Item label="Password" name={[fieldName, 'settings', 'password']}>
<Input />
</Form.Item>
<Form.Item label="ASCII" name={[fieldName, 'settings', 'ascii']}>
<Input />
</Form.Item>
<Form.Item label="Custom Table" name={[fieldName, 'settings', 'customTable']}>
<Input />
</Form.Item>
<Form.Item label="Custom Tables" name={[fieldName, 'settings', 'customTables']}>
<Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} />
</Form.Item>
<Form.Item label="Padding Min" name={[fieldName, 'settings', 'paddingMin']}>
<InputNumber min={0} />
</Form.Item>
<Form.Item label="Padding Max" name={[fieldName, 'settings', 'paddingMax']}>
<InputNumber min={0} />
</Form.Item>
</>
);
}
// xray's fragment `packets` accepts "tlshello" or an arbitrary packet-number
// range like "1-3" (#5075 — presets only covered the common cases).
function validateFragmentPackets(_rule: unknown, value: unknown): Promise<void> {
@ -737,6 +773,16 @@ function XmcProfilesList({ tcpFieldName }: { tcpFieldName: number }) {
);
}
const HEADER_CUSTOM_TCP_GROUPS = [
{ key: 'clients', label: 'Clients', tooltip: undefined },
{ key: 'servers', label: 'Servers', tooltip: undefined },
{
key: 'errors',
label: 'Errors',
tooltip: 'Sent instead when the matching client group fails to match, e.g. an HTTP 400',
},
] as const;
function HeaderCustomGroups({
tcpFieldName,
form,
@ -749,11 +795,11 @@ function HeaderCustomGroups({
const { t } = useTranslation();
return (
<>
{(['clients', 'servers'] as const).map((groupKey) => (
{HEADER_CUSTOM_TCP_GROUPS.map(({ key: groupKey, label, tooltip }) => (
<Form.List key={groupKey} name={[tcpFieldName, 'settings', groupKey]}>
{(groups, { add: addGroup, remove: removeGroup }) => (
<>
<Form.Item label={groupKey === 'clients' ? 'Clients' : 'Servers'}>
<Form.Item label={label} tooltip={tooltip}>
<Button
type="primary"
size="small"
@ -765,7 +811,7 @@ function HeaderCustomGroups({
{groups.map((group, gi) => (
<div key={group.key}>
<Divider style={{ margin: 0 }}>
{groupKey === 'clients' ? 'Clients' : 'Servers'} Group {gi + 1}
{label} Group {gi + 1}
<DeleteOutlined
className="danger-icon"
role="button"
@ -798,6 +844,7 @@ function HeaderCustomGroups({
item.name,
]}
delayMode="number"
allowVariables
onRemove={() => removeItem(item.name)}
/>
))}
@ -820,12 +867,14 @@ function UdpMasksList({
isHysteria,
isWireguard,
network,
allowUdpHop,
}: {
base: (string | number)[];
form: FormInstance;
isHysteria: boolean;
isWireguard: boolean;
network: string;
allowUdpHop: boolean;
}) {
const { t } = useTranslation();
return (
@ -854,6 +903,7 @@ function UdpMasksList({
isHysteria={isHysteria}
isWireguard={isWireguard}
network={network}
allowUdpHop={allowUdpHop}
onRemove={() => remove(field.name)}
/>
))}
@ -871,6 +921,7 @@ function UdpMaskItem({
isHysteria,
isWireguard,
network,
allowUdpHop,
onRemove,
}: {
fieldName: number;
@ -880,6 +931,7 @@ function UdpMaskItem({
isHysteria: boolean;
isWireguard: boolean;
network: string;
allowUdpHop: boolean;
onRemove: () => void;
}) {
const absolutePath = [...listPath, fieldName];
@ -893,8 +945,9 @@ function UdpMaskItem({
}
};
const udpHopOption = allowUdpHop ? [{ value: 'udphop', label: 'UDP Hop' }] : [];
const options = isHysteria
? [{ value: 'salamander', label: 'Salamander (Hysteria2)' }]
? [{ value: 'salamander', label: 'Salamander (Hysteria2)' }, ...udpHopOption]
: [
// Salamander is the mask xray-core's own wireguard finalmask example
// uses; it stays hysteria-only elsewhere to keep legacy parity.
@ -905,6 +958,8 @@ function UdpMaskItem({
{ value: 'realm', label: 'Realm' },
{ value: 'header-custom', label: 'Header Custom' },
{ value: 'noise', label: 'Noise' },
{ value: 'sudoku', label: 'Sudoku' },
...udpHopOption,
];
return (
@ -967,6 +1022,9 @@ function UdpMaskItem({
if (type === 'xdns') {
return <XdnsSettings udpFieldName={fieldName} />;
}
if (type === 'udphop') {
return <UdpHopSettings udpFieldName={fieldName} />;
}
if (type === 'xicmp') {
return (
<>
@ -1072,6 +1130,7 @@ function UdpMaskItem({
/>
);
}
if (type === 'sudoku') return <SudokuSettings fieldName={fieldName} />;
if (type === 'noise') {
return (
<NoiseItems
@ -1216,6 +1275,20 @@ function UdpHeaderCustom({
const { t } = useTranslation();
return (
<>
<Form.Item
label="Mode"
name={[udpFieldName, 'settings', 'mode']}
tooltip="Prefix puts the header on every packet; Standalone exchanges it as separate handshake packets first"
>
<Select
allowClear
placeholder="Prefix"
options={[
{ value: 'prefix', label: 'Prefix' },
{ value: 'standalone', label: 'Standalone' },
]}
/>
</Form.Item>
{(['client', 'server'] as const).map((groupKey) => (
<Form.List key={groupKey} name={[udpFieldName, 'settings', groupKey]}>
{(items, { add, remove }) => (
@ -1246,6 +1319,7 @@ function UdpHeaderCustom({
fieldName={item.name}
form={form}
absoluteItemPath={[...absoluteSettingsPath, groupKey, item.name]}
allowVariables
onRemove={() => remove(item.name)}
/>
</div>
@ -1280,7 +1354,7 @@ function XdnsSettings({ udpFieldName }: { udpFieldName: number }) {
size="small"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => add({ name: '', types: [16], edns0: XDNS_LEGACY_EDNS0 })}
onClick={() => add({ names: [], edns0: XDNS_LEGACY_EDNS0 })}
/>
</Form.Item>
{domains.map((domain, di) => (
@ -1296,15 +1370,20 @@ function XdnsSettings({ udpFieldName }: { udpFieldName: number }) {
onKeyDown={activateOnKey(() => remove(domain.name))}
/>
</Divider>
<Form.Item label="Name" name={[domain.name, 'name']}>
<Input placeholder="t.example.com" />
<Form.Item label="Names" name={[domain.name, 'names']}>
<Select
mode="tags"
style={{ width: '100%' }}
tokenSeparators={[',']}
placeholder="t.example.com"
/>
</Form.Item>
<Form.Item
label="Record Types"
name={[domain.name, 'types']}
rules={[{ required: true, type: 'array', min: 1 }]}
>
<Select mode="multiple" options={XDNS_RECORD_TYPE_OPTIONS} />
<Form.Item label="Record Types" name={[domain.name, 'types']}>
<Select
mode="multiple"
options={XDNS_RECORD_TYPE_OPTIONS}
placeholder="TXT (client), all (server)"
/>
</Form.Item>
<Form.Item label="EDNS0" name={[domain.name, 'edns0']}>
<InputNumber min={512} max={4096} placeholder="off" />
@ -1329,24 +1408,20 @@ function XdnsSettings({ udpFieldName }: { udpFieldName: number }) {
size="small"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => add({ type: 'udp', settings: { addr: '' } })}
onClick={() => add({ addrs: [] })}
/>
</Form.Item>
{resolvers.map((resolver, ri) => (
<Form.Item key={resolver.key} label={`Resolver ${ri + 1}`}>
<Space.Compact block>
<Form.Item name={[resolver.name, 'type']} noStyle>
<Form.Item name={[resolver.name, 'addrs']} noStyle>
<Select
style={{ width: 80 }}
options={[
{ value: 'udp', label: 'UDP' },
{ value: 'tcp', label: 'TCP' },
]}
mode="tags"
style={{ width: '100%' }}
tokenSeparators={[',', ' ']}
placeholder="udp://8.8.8.8:53, tcp://1.1.1.1"
/>
</Form.Item>
<Form.Item name={[resolver.name, 'settings', 'addr']} noStyle>
<Input placeholder="8.8.8.8:53" />
</Form.Item>
<Button
icon={<DeleteOutlined />}
aria-label={t('remove')}
@ -1365,6 +1440,79 @@ function XdnsSettings({ udpFieldName }: { udpFieldName: number }) {
);
}
const UDP_HOP_MODES = [
{ value: 'intervalRemote', label: 'Rotate server port (interval)' },
{ value: 'perConnRemote', label: 'Random server port per connection' },
{ value: 'intervalLocal', label: 'Rotate local port (interval)' },
];
// xray-core lowercases each comma-separated mode, so a lowercase one (from an mport
// import) still maps onto its option here.
function udpHopModesFromWire(value: unknown): string[] {
if (typeof value !== 'string') return [];
return value
.split(',')
.map((mode) => mode.trim())
.filter(Boolean)
.map(
(mode) =>
UDP_HOP_MODES.find((m) => m.value.toLowerCase() === mode.toLowerCase())?.value ?? mode,
);
}
function validateUdpHopInterval(_: unknown, value: unknown) {
if (value == null || value === '') return Promise.resolve();
const bounds = String(value)
.split('-')
.map((v) => Number(v.trim()));
const valid = bounds.length <= 2 && bounds.every((v) => Number.isInteger(v) && v >= 5);
return valid ? Promise.resolve() : Promise.reject(new Error('seconds ≥ 5, e.g. 30 or 5-10'));
}
// Client-only hopping (xray-core 26.9.9); every key needs a registered field because the
// finalmask watch drops keys without one.
function UdpHopSettings({ udpFieldName }: { udpFieldName: number }) {
return (
<>
<Form.Item
label="Mode"
name={[udpFieldName, 'settings', 'mode']}
getValueProps={(value) => ({ value: udpHopModesFromWire(value) })}
normalize={(value: string[]) => value.join(',')}
rules={[{ required: true, message: 'pick at least one mode' }]}
>
<Select mode="multiple" options={UDP_HOP_MODES} />
</Form.Item>
<Form.Item
label="Interval (s)"
name={[udpFieldName, 'settings', 'interval']}
rules={[{ validator: validateUdpHopInterval }]}
>
<Input placeholder="30 or 5-10" />
</Form.Item>
<Form.Item label="Remote Ports" name={[udpFieldName, 'settings', 'remotePorts']}>
<Input placeholder="20000-50000" />
</Form.Item>
<Form.Item label="Remote IPs" name={[udpFieldName, 'settings', 'remoteIPs']}>
<Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',', ' ']} />
</Form.Item>
</>
);
}
// xray-core reads reset as an Int32Range: a plain number or a "min-max" string.
function validateNoiseReset(_: unknown, value: unknown) {
if (value == null || value === '') return Promise.resolve();
const bounds = String(value)
.split('-')
.map((v) => Number(v.trim()));
const valid =
bounds.length <= 2 &&
bounds.every((v) => Number.isInteger(v) && v >= 0) &&
bounds[0] <= bounds[bounds.length - 1];
return valid ? Promise.resolve() : Promise.reject(new Error('seconds, e.g. 0 or 60-120'));
}
function NoiseItems({
udpFieldName,
form,
@ -1377,8 +1525,13 @@ function NoiseItems({
const { t } = useTranslation();
return (
<>
<Form.Item label="Reset" name={[udpFieldName, 'settings', 'reset']}>
<InputNumber min={0} />
<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 }) => (
@ -1421,20 +1574,98 @@ function NoiseItems({
);
}
// xray-core 26.6 header-custom: an item is exactly one of raw bytes, a reused variable
// or a transform ("exactly one item kind must be set"); capture works with any of them.
type ItemKind = 'bytes' | 'reuse' | 'transform';
const ITEM_KIND_OPTIONS: { value: ItemKind; label: string }[] = [
{ value: 'bytes', label: 'Bytes' },
{ value: 'reuse', label: 'Reuse variable' },
{ value: 'transform', label: 'Transform' },
];
function itemKindOf(item: unknown): ItemKind {
const fields = (item ?? {}) as Record<string, unknown>;
if (typeof fields.reuse === 'string') return 'reuse';
if (fields.transform != null) return 'transform';
return 'bytes';
}
function validateCustomVarName(_: unknown, value: unknown) {
if (value == null || value === '') return Promise.resolve();
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(String(value))
? Promise.resolve()
: Promise.reject(new Error('letters, digits and _, not starting with a digit'));
}
// Hands the form only a parsed JSON object, so half-typed text never reaches the config.
function TransformEditor({
value,
onChange,
}: {
value?: Record<string, unknown>;
onChange?: (next: Record<string, unknown> | undefined) => void;
}) {
const [text, setText] = useState(() => (value ? JSON.stringify(value) : ''));
const [invalid, setInvalid] = useState(false);
return (
<>
<Input.TextArea
value={text}
autoSize={{ minRows: 2, maxRows: 8 }}
placeholder='{"op": "concat", "args": [{"reuse": "nonce"}]}'
onChange={(e) => {
const next = e.target.value;
setText(next);
if (next.trim() === '') {
setInvalid(false);
onChange?.(undefined);
return;
}
try {
const parsed: unknown = JSON.parse(next);
const isObject = !!parsed && typeof parsed === 'object' && !Array.isArray(parsed);
setInvalid(!isObject);
if (isObject) onChange?.(parsed as Record<string, unknown>);
} catch {
setInvalid(true);
}
}}
/>
{invalid && <Typography.Text type="danger">Must be a JSON object</Typography.Text>}
</>
);
}
function ItemEditor({
fieldName,
form,
absoluteItemPath,
delayMode,
allowVariables = false,
onRemove: _onRemove,
}: {
fieldName: number;
form: FormInstance;
absoluteItemPath: (string | number)[];
delayMode?: 'number' | 'string';
allowVariables?: boolean;
onRemove?: () => void;
}) {
const { t } = useTranslation();
const [kind, setKind] = useState<ItemKind>(() =>
allowVariables ? itemKindOf(form.getFieldValue(absoluteItemPath)) : 'bytes',
);
const onKindChange = (next: ItemKind) => {
setKind(next);
for (const key of ['type', 'packet', 'rand', 'randRange', 'reuse', 'transform']) {
form.setFieldValue([...absoluteItemPath, key], undefined);
}
if (next === 'bytes') {
form.setFieldValue([...absoluteItemPath, 'type'], 'array');
form.setFieldValue([...absoluteItemPath, 'rand'], 0);
}
};
/**
* Switching to `array` clears the packet instead of emptying it to `[]`:
* that branch is rand-driven, and xray-core counts even an empty array as a
@ -1454,19 +1685,26 @@ function ItemEditor({
return (
<>
<Form.Item label="Type" name={[fieldName, 'type']}>
<Select
onChange={onTypeChange}
options={[
{ value: 'array', label: 'Array' },
{ value: 'str', label: 'String' },
{ value: 'hex', label: 'Hex' },
{ value: 'base64', label: 'Base64' },
// Only the noise mask parses tag expressions (xray-core 26.9.30, #6862).
...(delayMode === 'string' ? [{ value: 'exp', label: 'Expression' }] : []),
]}
/>
</Form.Item>
{allowVariables && (
<Form.Item label="Kind">
<Select value={kind} onChange={onKindChange} options={ITEM_KIND_OPTIONS} />
</Form.Item>
)}
{kind === 'bytes' && (
<Form.Item label="Type" name={[fieldName, 'type']}>
<Select
onChange={onTypeChange}
options={[
{ value: 'array', label: 'Array' },
{ value: 'str', label: 'String' },
{ value: 'hex', label: 'Hex' },
{ value: 'base64', label: 'Base64' },
// Only the noise mask parses tag expressions (xray-core 26.9.30, #6862).
...(delayMode === 'string' ? [{ value: 'exp', label: 'Expression' }] : []),
]}
/>
</Form.Item>
)}
{delayMode === 'number' && (
<Form.Item label="Delay (ms)" name={[fieldName, 'delay']}>
@ -1479,6 +1717,30 @@ function ItemEditor({
</Form.Item>
)}
{kind === 'reuse' && (
<Form.Item
label="Reuse"
name={[fieldName, 'reuse']}
tooltip="Writes the bytes an earlier item captured under this name"
rules={[
{ required: true, message: 'name the captured variable' },
{ validator: validateCustomVarName },
]}
>
<Input placeholder="variable name" />
</Form.Item>
)}
{kind === 'transform' && (
<Form.Item
label="Transform"
name={[fieldName, 'transform']}
tooltip='An expression tree, e.g. {"op": "concat", "args": [{"type": "str", "bytes": "OK "}, {"reuse": "nonce"}]}'
rules={[{ required: true, message: 'enter the transform expression' }]}
>
<TransformEditor />
</Form.Item>
)}
<Form.Item
noStyle
shouldUpdate={(prev, curr) =>
@ -1487,6 +1749,7 @@ function ItemEditor({
}
>
{({ getFieldValue }) => {
if (kind !== 'bytes') return null;
const type = getFieldValue([...absoluteItemPath, 'type']) as string | undefined;
if (type === 'array') {
return (
@ -1537,11 +1800,31 @@ function ItemEditor({
);
}}
</Form.Item>
{allowVariables && (
<Form.Item
label="Capture"
name={[fieldName, 'capture']}
tooltip="Saves the bytes this item writes or reads under a name later items can reuse"
normalize={(v) => (v === '' ? undefined : v)}
rules={[{ validator: validateCustomVarName }]}
>
<Input placeholder="variable name" />
</Form.Item>
)}
</>
);
}
function QuicParamsForm({ base, form }: { base: (string | number)[]; form: FormInstance }) {
function QuicParamsForm({
base,
form,
showUdpHop,
}: {
base: (string | number)[];
form: FormInstance;
showUdpHop: boolean;
}) {
const congestion = Form.useWatch([...base, 'congestion'], form) as string | undefined;
const udpHop = Form.useWatch([...base, 'udpHop'], { form, preserve: true }) as
| Record<string, unknown>
@ -1595,22 +1878,31 @@ function QuicParamsForm({ base, form }: { base: (string | number)[]; form: FormI
</>
)}
<Form.Item label="UDP Hop">
<Switch
checked={hasUdpHop}
onChange={(v) => {
form.setFieldValue([...base, 'udpHop'], v ? defaultUdpHop() : undefined);
}}
/>
</Form.Item>
{hasUdpHop && (
{/* Advertised to clients (link mport, JSON-subscription udphop mask); the server
itself listens on its own port. */}
{showUdpHop && (
<>
<Form.Item label="Hop Ports" name={[...base, 'udpHop', 'ports']}>
<Input placeholder="e.g. 20000-50000" />
</Form.Item>
<Form.Item label="Hop Interval (s)" name={[...base, 'udpHop', 'interval']}>
<Input placeholder="e.g. 5-10" />
<Form.Item
label="UDP Hop"
tooltip="Advertised to clients as a hop range; the server listens on its own port only"
>
<Switch
checked={hasUdpHop}
onChange={(v) => {
form.setFieldValue([...base, 'udpHop'], v ? defaultUdpHop() : undefined);
}}
/>
</Form.Item>
{hasUdpHop && (
<>
<Form.Item label="Hop Ports" name={[...base, 'udpHop', 'ports']}>
<Input placeholder="e.g. 20000-50000" />
</Form.Item>
<Form.Item label="Hop Interval (s)" name={[...base, 'udpHop', 'interval']}>
<Input placeholder="e.g. 5-10" />
</Form.Item>
</>
)}
</>
)}

View file

@ -0,0 +1,148 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Input, InputNumber, Select, Typography } from 'antd';
import { useFormContext, useWatch } from 'react-hook-form';
import { JsonEditor } from '@/components/form';
import { FormField } from '@/components/form/rhf';
import { XDriveServiceSchema } from '@/schemas/protocols/stream/xdrive';
const BASE = ['streamSettings', 'xdriveSettings'] as const;
const SERVICE_LABEL_KEYS: Partial<Record<string, string>> = {
local: 'pages.inbounds.form.xdrive.serviceLocal',
template: 'pages.inbounds.form.xdrive.serviceTemplate',
};
// Core defaults (transport/internet/xdrive/params.go), shown as placeholders so an
// empty field keeps meaning "use the core's value".
const TUNING_FIELDS = [
['segmentBytes', 'segmentBytes', 524288],
['flushIntervalMs', 'flushInterval', 20],
['pollIntervalMs', 'pollInterval', 50],
['maxPollIntervalMs', 'maxPollInterval', 500],
['eagerWindowMs', 'eagerWindow', 2000],
['holeTimeoutMs', 'holeTimeout', 30000],
['sessionTtlSeconds', 'sessionTtl', 300],
['concurrency', 'concurrency', 8],
] as const;
const GOOGLE_DRIVE_SECRETS = ['clientId', 'clientSecret', 'refreshToken'] as const;
function TemplateEditor({
value,
onChange,
}: {
value?: Record<string, unknown>;
onChange?: (next: Record<string, unknown> | undefined) => void;
}) {
const { t } = useTranslation();
const [text, setText] = useState(() => (value ? JSON.stringify(value, null, 2) : ''));
const [invalid, setInvalid] = useState(false);
return (
<>
<JsonEditor
value={text}
minHeight="160px"
onChange={(next) => {
setText(next);
if (next.trim() === '') {
setInvalid(false);
onChange?.(undefined);
return;
}
try {
const parsed: unknown = JSON.parse(next);
const isObject = !!parsed && typeof parsed === 'object' && !Array.isArray(parsed);
setInvalid(!isObject);
if (isObject) onChange?.(parsed as Record<string, unknown>);
} catch {
setInvalid(true);
}
}}
/>
{invalid && (
<Typography.Text type="danger">
{t('pages.inbounds.form.xdrive.templateInvalid')}
</Typography.Text>
)}
</>
);
}
export default function XDriveForm() {
const { t } = useTranslation();
const { control } = useFormContext();
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 (
<>
<FormField label={t('pages.inbounds.form.xdrive.service')} name={[...BASE, 'service']}>
<Select options={serviceOptions} />
</FormField>
<FormField
label={t('pages.inbounds.form.xdrive.remoteFolder')}
name={[...BASE, 'remoteFolder']}
required
>
<Input placeholder={service === 'local' ? '/var/lib/xdrive' : 'xray-tunnel'} />
</FormField>
{service === 'Google Drive' &&
GOOGLE_DRIVE_SECRETS.map((key, index) => (
<FormField
key={key}
label={t(`pages.inbounds.form.xdrive.${key}`)}
name={[...BASE, 'secrets', index]}
required
>
{key === 'clientId' ? <Input /> : <Input.Password />}
</FormField>
))}
{service === 'template' && (
<>
<FormField
label={t('pages.inbounds.form.xdrive.secrets')}
tooltip={t('pages.inbounds.form.xdrive.secretsDesc')}
name={[...BASE, 'secrets']}
>
<Select mode="tags" tokenSeparators={[',']} open={false} />
</FormField>
<FormField
label={t('pages.inbounds.form.xdrive.template')}
name={[...BASE, 'template']}
required
>
<TemplateEditor />
</FormField>
</>
)}
{service !== 'local' && (
<>
<FormField
label={t('pages.inbounds.form.xdrive.front')}
tooltip={t('pages.inbounds.form.xdrive.frontDesc')}
name={['streamSettings', 'address']}
transform={{ output: (raw) => String(raw ?? '').trim() || undefined }}
>
<Input placeholder="www.googleapis.com" />
</FormField>
<FormField
label={t('pages.inbounds.form.xdrive.frontPort')}
name={['streamSettings', 'port']}
>
<InputNumber min={1} max={65535} placeholder="443" />
</FormField>
</>
)}
{TUNING_FIELDS.map(([key, label, placeholder]) => (
<FormField key={key} label={t(`pages.inbounds.form.xdrive.${label}`)} name={[...BASE, key]}>
<InputNumber min={0} placeholder={String(placeholder)} style={{ width: '100%' }} />
</FormField>
))}
</>
);
}

View file

@ -4,6 +4,10 @@ import { generateAwgObfuscation } from '@/lib/xray/amneziawg-obfuscation';
import type { AmneziawgInboundSettings } from '@/schemas/protocols/inbound/amneziawg';
import type { HttpInboundSettings } from '@/schemas/protocols/inbound/http';
import type { HysteriaClient, HysteriaInboundSettings } from '@/schemas/protocols/inbound/hysteria';
import {
MASQUE_DEFAULT_ADDRESS_POOL,
type MasqueInboundSettings,
} from '@/schemas/protocols/inbound/masque';
import type { MixedInboundSettings } from '@/schemas/protocols/inbound/mixed';
import type { MtprotoClient, MtprotoInboundSettings } from '@/schemas/protocols/inbound/mtproto';
import type {
@ -338,6 +342,10 @@ export function createDefaultAmneziawgInboundSettings(): AmneziawgInboundSetting
};
}
export function createDefaultMasqueInboundSettings(): MasqueInboundSettings {
return { clients: [], address: [...MASQUE_DEFAULT_ADDRESS_POOL] };
}
export function createDefaultTuicInboundSettings(): TuicInboundSettings {
return {
server: {
@ -375,7 +383,8 @@ export type AnyInboundSettings =
| WireguardInboundSettings
| MtprotoInboundSettings
| AmneziawgInboundSettings
| TuicInboundSettings;
| TuicInboundSettings
| MasqueInboundSettings;
export function createDefaultInboundSettings(protocol: string): AnyInboundSettings | null {
switch (protocol) {
@ -405,6 +414,8 @@ export function createDefaultInboundSettings(protocol: string): AnyInboundSettin
return createDefaultAmneziawgInboundSettings();
case 'tuic':
return createDefaultTuicInboundSettings();
case 'masque':
return createDefaultMasqueInboundSettings();
default:
return null;
}

View file

@ -8,6 +8,7 @@ import type { InboundSettings } from '@/schemas/protocols/inbound';
import {
AmneziawgClientSchema,
HysteriaClientSchema,
MasqueClientSchema,
MtprotoClientSchema,
ShadowsocksClientSchema,
TrojanClientSchema,
@ -139,6 +140,8 @@ const NETWORK_SETTINGS_KEY: Record<string, string> = {
httpupgrade: 'httpupgradeSettings',
xhttp: 'xhttpSettings',
hysteria: 'hysteriaSettings',
xdrive: 'xdriveSettings',
masque: 'masqueSettings',
};
function healStreamNetworkKey(stream: Record<string, unknown>): void {
@ -288,6 +291,8 @@ function clientSchemaForProtocol(protocol: string): z.ZodType | null {
return AmneziawgClientSchema;
case 'tuic':
return TuicClientSchema;
case 'masque':
return MasqueClientSchema;
default:
return null;
}

View file

@ -1449,6 +1449,11 @@ export interface GenLinkInput {
externalProxy?: ExternalProxyEntry | null;
}
// XDRIVE has no link format; a link carrying its storage secrets would leak them.
function hasNoLinkFormat(inbound: Inbound): boolean {
return inbound.streamSettings?.network === 'xdrive';
}
// Per-protocol dispatcher matching the legacy `genLink` switch. Returns
// '' for protocols that don't have client-based share links (wireguard
// goes through genWireguardLinks/Configs separately, http/mixed/tunnel
@ -1463,6 +1468,7 @@ export function genLink(input: GenLinkInput): string {
client,
externalProxy = null,
} = input;
if (hasNoLinkFormat(inbound)) return '';
switch (inbound.protocol) {
case 'vmess':
return genVmessLink({
@ -1603,6 +1609,7 @@ export interface GenInboundLinksInput {
// other clientless protocols (http, mixed, tunnel).
export function genInboundLinks(input: GenInboundLinksInput): string {
const { inbound, remark = '', hostOverride = '', fallbackHostname } = input;
if (hasNoLinkFormat(inbound)) return '';
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
const clients = getInboundClients(inbound);
if (clients) {

View file

@ -21,6 +21,7 @@ function inboundTransports(
protocol === 'tuic'
)
return UDP;
if (protocol === 'masque') return masqueTransports(streamSettings);
let bits: TransportBits = 0;
const network = asString(streamSettings?.network);
@ -48,6 +49,18 @@ function inboundTransports(
return bits;
}
// MASQUE listens HTTP/2 on TCP when the ALPN offers h2, HTTP/3 on UDP when it offers
// h3 or does not offer h2.
function masqueTransports(streamSettings: Record<string, unknown> | undefined): TransportBits {
const tls = streamSettings?.tlsSettings as { alpn?: unknown } | undefined;
const alpn = Array.isArray(tls?.alpn) ? (tls.alpn as unknown[]) : [];
const h2 = alpn.includes('h2');
let bits: TransportBits = 0;
if (h2) bits |= TCP;
if (!h2 || alpn.includes('h3')) bits |= UDP;
return bits;
}
function transportTagSuffix(bits: TransportBits): string {
if (bits === TCP) return 'tcp';
if (bits === UDP) return 'udp';

View file

@ -14,7 +14,7 @@ function defaultCertificate(): Record<string, unknown> {
};
}
export function createTlsSettingsWithDefaultCert(): Record<string, unknown> {
export function createTlsSettingsWithDefaultCert(network?: string): Record<string, unknown> {
const tls = TlsStreamSettingsSchema.parse({}) as Record<string, unknown>;
tls.certificates = [defaultCertificate()];
const settings =
@ -23,9 +23,19 @@ export function createTlsSettingsWithDefaultCert(): Record<string, unknown> {
: {};
settings.fingerprint = 'chrome';
tls.settings = settings;
/* WebSocket bootstraps over HTTP/1.1: the schema default ALPN
* ['h2','http/1.1'] makes the server negotiate h2 (see issue #6782). */
if (network === 'ws') {
tls.alpn = ['http/1.1'];
}
return tls;
}
// MASQUE serves HTTP/3 over QUIC by default, so it takes Hysteria's QUIC-ready TLS.
export function createMasqueTlsSettingsWithDefaultCert(): Record<string, unknown> {
return createHysteriaTlsSettingsWithDefaultCert();
}
export function createHysteriaTlsSettingsWithDefaultCert(): Record<string, unknown> {
const tls = createTlsSettingsWithDefaultCert();
tls.alpn = ['h3'];

View file

@ -17,4 +17,5 @@ export const NODE_ELIGIBLE_PROTOCOLS: Readonly<Record<string, true>> = {
[Protocols.MTPROTO]: true,
[Protocols.AMNEZIAWG]: true,
[Protocols.TUIC]: true,
[Protocols.MASQUE]: true,
};

View file

@ -5,6 +5,7 @@ import type { DNSOutboundSettings } from '@/schemas/protocols/outbound/dns';
import type { FreedomOutboundSettings } from '@/schemas/protocols/outbound/freedom';
import type { HttpOutboundSettings } from '@/schemas/protocols/outbound/http';
import type { HysteriaOutboundSettings } from '@/schemas/protocols/outbound/hysteria';
import type { MasqueOutboundSettings } from '@/schemas/protocols/outbound/masque';
import type { LoopbackOutboundSettings } from '@/schemas/protocols/outbound/loopback';
import type { ShadowsocksOutboundSettings } from '@/schemas/protocols/outbound/shadowsocks';
import type { SocksOutboundSettings } from '@/schemas/protocols/outbound/socks';
@ -130,12 +131,17 @@ export function createDefaultHysteriaOutboundSettings(): HysteriaOutboundSetting
return { address: '', port: 443, version: 2 };
}
export function createDefaultMasqueOutboundSettings(): MasqueOutboundSettings {
return { address: '', port: 443 };
}
export type AnyOutboundSettings =
| BlackholeOutboundSettings
| DNSOutboundSettings
| FreedomOutboundSettings
| HttpOutboundSettings
| HysteriaOutboundSettings
| MasqueOutboundSettings
| LoopbackOutboundSettings
| ShadowsocksOutboundSettings
| SocksOutboundSettings
@ -177,6 +183,8 @@ export function createDefaultOutboundSettings(protocol: string): AnyOutboundSett
return createDefaultWireguardOutboundSettings();
case 'hysteria':
return createDefaultHysteriaOutboundSettings();
case 'masque':
return createDefaultMasqueOutboundSettings();
case 'loopback':
return createDefaultLoopbackOutboundSettings();
default:

View file

@ -18,6 +18,7 @@ import type {
FreedomOutboundFormSettings,
HttpOutboundFormSettings,
HysteriaOutboundFormSettings,
MasqueOutboundFormSettings,
LoopbackOutboundFormSettings,
MuxForm,
OutboundFormSettings,
@ -313,6 +314,14 @@ function hysteriaFromWire(raw: Raw): HysteriaOutboundFormSettings {
};
}
function masqueFromWire(raw: Raw): MasqueOutboundFormSettings {
return {
address: asString(raw.address),
port: asPort(raw.port, 443),
remoteDNS: asArray(raw.remoteDNS).filter((v): v is string => typeof v === 'string'),
};
}
function freedomFromWire(
raw: Raw,
domainStrategy: OutboundDomainStrategy | '',
@ -618,6 +627,9 @@ export function rawOutboundToFormValues(raw: RawOutboundRow): OutboundFormValues
case 'hysteria':
typed = { protocol: 'hysteria', settings: hysteriaFromWire(settings) };
break;
case 'masque':
typed = { protocol: 'masque', settings: masqueFromWire(settings) };
break;
case 'freedom':
typed = {
protocol: 'freedom',
@ -780,6 +792,15 @@ function hysteriaToWire(s: HysteriaOutboundFormSettings) {
return { address: s.address, port: s.port, version: s.version };
}
function masqueToWire(s: MasqueOutboundFormSettings) {
const remoteDNS = s.remoteDNS.filter(Boolean);
return {
address: s.address,
port: s.port,
remoteDNS: remoteDNS.length > 0 ? remoteDNS : undefined,
};
}
function freedomToWire(s: FreedomOutboundFormSettings) {
// Legacy semantics: emit fragment only when the user actually populated
// at least one of the four sub-fields. Defaults like packets='1-3' alone
@ -857,7 +878,7 @@ function loopbackToWire(s: LoopbackOutboundFormSettings) {
// canEnableMux mirrors the legacy Outbound.canEnableMux().
const MUX_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'shadowsocks', 'http', 'socks']);
const STREAM_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'shadowsocks', 'hysteria']);
const STREAM_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'shadowsocks', 'hysteria', 'masque']);
function dropEmptyStrings(obj: Raw): Raw {
const out: Raw = {};
@ -926,6 +947,9 @@ export function formValuesToWirePayload(values: OutboundFormValues): WireOutboun
case 'hysteria':
settings = hysteriaToWire(values.settings);
break;
case 'masque':
settings = masqueToWire(values.settings);
break;
case 'freedom':
settings = freedomToWire(values.settings);
break;

View file

@ -1,5 +1,6 @@
import { Base64 } from '@/utils';
import { upgradeLegacyUdpHop } from './udphop-mask';
import { upgradeLegacyXdnsMasks } from './xdns-mask';
// Focused share-link parser for the OutboundFormModal's link-import
@ -272,8 +273,8 @@ const kcpHeaderTypeToMask: Record<string, string> = {
};
// The inbound link emits the entire finalmask object as a JSON-encoded
// `fm` query param. Decode and attach to streamSettings so udpHop /
// quicParams / tcp+udp masks round-trip on outbound import.
// `fm` query param. Decode and attach to streamSettings so quicParams and the
// tcp+udp masks round-trip on outbound import; a legacy udpHop becomes the mask.
function applyFinalMaskParam(stream: Raw, params: URLSearchParams): void {
const fm = params.get('fm');
if (fm) {
@ -282,7 +283,7 @@ function applyFinalMaskParam(stream: Raw, params: URLSearchParams): void {
if (parsed && typeof parsed === 'object') {
sanitizeFinalMaskQuicParams(parsed);
if (Array.isArray(parsed.udp)) parsed.udp = upgradeLegacyXdnsMasks(parsed.udp).next;
stream.finalmask = parsed;
stream.finalmask = upgradeLegacyUdpHop(parsed).next;
}
} catch {
// malformed fm — leave streamSettings.finalmask absent

View file

@ -17,6 +17,7 @@ const STREAM_PROTOCOLS = [
'hysteria',
'wireguard',
'tunnel',
'masque',
];
const VISION_FLOW = 'xtls-rprx-vision';
const SS_2022_PREFIX = '2022';
@ -37,11 +38,20 @@ export interface CapabilityShadowsocksSlice extends CapabilityProtocolSlice {
}
export function canEnableTls(values: CapabilityProtocolSlice): boolean {
if (values.protocol === 'hysteria') return true;
if (values.protocol === 'hysteria' || values.protocol === 'masque') return true;
if (!TLS_ELIGIBLE_PROTOCOLS.includes(values.protocol)) return false;
return TLS_NETWORKS.includes(values.streamSettings?.network ?? '');
}
// An XDRIVE outbound's tlsSettings shape only its HTTPS calls to the storage API (SNI, uTLS
// fingerprint); xray-core 26.10.10 reads them on the dial side, never on a listener.
export function canEnableOutboundTls(values: CapabilityProtocolSlice): boolean {
if (canEnableTls(values)) return true;
return (
TLS_ELIGIBLE_PROTOCOLS.includes(values.protocol) && values.streamSettings?.network === 'xdrive'
);
}
export function canEnableReality(values: CapabilityProtocolSlice): boolean {
if (!REALITY_ELIGIBLE_PROTOCOLS.includes(values.protocol)) return false;
return REALITY_NETWORKS.includes(values.streamSettings?.network ?? '');

View file

@ -5,6 +5,7 @@ import {
KcpStreamSettingsSchema,
TcpStreamSettingsSchema,
WsStreamSettingsSchema,
XDriveStreamSettingsSchema,
XHttpStreamSettingsSchema,
} from '@/schemas/protocols/stream';
import { RealityStreamSettingsSchema, TlsStreamSettingsSchema } from '@/schemas/protocols/security';
@ -17,6 +18,7 @@ const NETWORK_KEY_MAP = {
httpupgrade: 'httpupgradeSettings',
xhttp: 'xhttpSettings',
hysteria: 'hysteriaSettings',
xdrive: 'xdriveSettings',
} as const;
type SchemaWithParse = { safeParse: (v: unknown) => { success: boolean; data?: unknown } };
@ -44,6 +46,8 @@ function networkSchemaFor(network: string): SchemaWithParse | null {
return XHttpStreamSettingsSchema;
case 'hysteria':
return HysteriaStreamSettingsSchema;
case 'xdrive':
return XDriveStreamSettingsSchema;
default:
return null;
}

View file

@ -0,0 +1,29 @@
type Raw = Record<string, unknown>;
function isEmptyHopValue(value: unknown): boolean {
return value == null || value === 0 || (typeof value === 'string' && value.trim() === '');
}
// xray-core 26.9.9 hops a client only through the "udphop" UDP mask. Mirrors
// internal/util/maskcompat: quicParams.udpHop becomes an intervalRemote mask.
export function upgradeLegacyUdpHop(finalmask: Raw): { next: Raw; changed: boolean } {
const quicParams = finalmask.quicParams;
if (!quicParams || typeof quicParams !== 'object' || !('udpHop' in quicParams)) {
return { next: finalmask, changed: false };
}
const { udpHop, ...restQuic } = quicParams as Raw;
const next: Raw = { ...finalmask };
if (Object.keys(restQuic).length > 0) next.quicParams = restQuic;
else delete next.quicParams;
const hop = (udpHop && typeof udpHop === 'object' ? udpHop : {}) as Raw;
const udp = Array.isArray(finalmask.udp) ? [...(finalmask.udp as unknown[])] : [];
const hasMask = udp.some((m) => String((m as Raw | null)?.type ?? '').toLowerCase() === 'udphop');
if (!isEmptyHopValue(hop.ports) && !hasMask) {
const settings: Raw = { mode: 'intervalRemote', remotePorts: hop.ports };
if (!isEmptyHopValue(hop.interval)) settings.interval = hop.interval;
udp.push({ type: 'udphop', settings });
next.udp = udp;
}
return { next, changed: true };
}

View file

@ -5,6 +5,10 @@ export const XDNS_LEGACY_EDNS0 = 1232;
const LEGACY_RECORD_TYPES: Record<string, number> = { '': 16, txt: 16, a: 1, aaaa: 28 };
function isRaw(value: unknown): value is Raw {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function legacyDomain(spec: string): Raw | null {
let name = spec.trim();
let method = '';
@ -16,46 +20,73 @@ function legacyDomain(spec: string): Raw | null {
name = name.replace(/^\.+|\.+$/g, '');
const type = LEGACY_RECORD_TYPES[method];
if (!name || type === undefined) return null;
return { name, types: [type], edns0: XDNS_LEGACY_EDNS0 };
return { names: [name], types: [type], edns0: XDNS_LEGACY_EDNS0 };
}
// xray-core 26.9.30 (#6718) parses xdns domains/resolvers only as objects. Mirrors
/** Folds a 26.9.30 domain's single `name` into the `names` list. */
function upgradeDomainObject(domain: Raw): Raw {
if (!('name' in domain)) return domain;
const { name: raw, ...rest } = domain;
const name = typeof raw === 'string' ? raw.trim() : '';
const names = Array.isArray(rest.names) ? [...(rest.names as unknown[])] : [];
if (name && !names.includes(name)) names.unshift(name);
return { ...rest, names };
}
/** A 26.9.30 {type, settings:{addr}} resolver as an addrs URL; that loader took only udp/tcp. */
function legacyResolverAddr(resolver: Raw): string | null {
const kind = typeof resolver.type === 'string' ? resolver.type.trim().toLowerCase() : '';
const settings = isRaw(resolver.settings) ? resolver.settings : {};
const addr = typeof settings.addr === 'string' ? settings.addr.trim() : '';
if ((kind !== 'udp' && kind !== 'tcp') || !addr) return null;
return `${kind}://${addr}`;
}
const isLegacyDomain = (v: unknown) => typeof v === 'string' || (isRaw(v) && 'name' in v);
const isLegacyResolver = (v: unknown) => typeof v === 'string' || (isRaw(v) && !('addrs' in v));
// xray-core 26.10.10 (#7090) reads xdns only as names/addrs lists. Mirrors
// internal/util/maskcompat: a bare name becomes TXT, entries the old core refused are dropped.
export function upgradeLegacyXdnsSettings(settings: Raw): { next: Raw; changed: boolean } {
const rawDomains = Array.isArray(settings.domains) ? (settings.domains as unknown[]) : [];
const rawResolvers = Array.isArray(settings.resolvers) ? (settings.resolvers as unknown[]) : [];
const isLegacy = (v: unknown) => typeof v === 'string';
if (!rawDomains.some(isLegacy) && !rawResolvers.some(isLegacy)) {
if (!rawDomains.some(isLegacyDomain) && !rawResolvers.some(isLegacyResolver)) {
return { next: settings, changed: false };
}
const domains: unknown[] = [];
const listed = new Set<string>();
const addDomain = (domain: Raw) => {
const key = String(domain.name ?? '').toLowerCase();
if (key && listed.has(key)) return;
listed.add(key);
const names = Array.isArray(domain.names) ? (domain.names as unknown[]) : [];
for (const name of names) if (typeof name === 'string') listed.add(name.toLowerCase());
domains.push(domain);
};
const addLegacyDomain = (domain: Raw) => {
const [name] = domain.names as string[];
if (!listed.has(name.toLowerCase())) addDomain(domain);
};
for (const entry of rawDomains) {
if (typeof entry === 'string') {
const domain = legacyDomain(entry);
if (domain) addDomain(domain);
} else if (entry && typeof entry === 'object') {
addDomain(entry as Raw);
if (domain) addLegacyDomain(domain);
} else if (isRaw(entry)) {
addDomain(upgradeDomainObject(entry));
}
}
const resolvers: unknown[] = [];
for (const entry of rawResolvers) {
if (typeof entry !== 'string') {
if (typeof entry === 'string') {
const sep = entry.indexOf('+udp://');
const addr = sep >= 0 ? entry.slice(sep + '+udp://'.length).trim() : '';
const domain = sep >= 0 ? legacyDomain(entry.slice(0, sep)) : null;
if (!addr || !domain) continue;
addLegacyDomain(domain);
resolvers.push({ addrs: [`udp://${addr}`] });
} else if (isRaw(entry) && isLegacyResolver(entry)) {
const addr = legacyResolverAddr(entry);
if (addr) resolvers.push({ addrs: [addr] });
} else {
resolvers.push(entry);
continue;
}
const sep = entry.indexOf('+udp://');
const addr = sep >= 0 ? entry.slice(sep + '+udp://'.length).trim() : '';
const domain = sep >= 0 ? legacyDomain(entry.slice(0, sep)) : null;
if (!addr || !domain) continue;
addDomain(domain);
resolvers.push({ type: 'udp', settings: { addr } });
}
const next: Raw = { ...settings, domains };
if (resolvers.length > 0) next.resolvers = resolvers;

View file

@ -2138,7 +2138,7 @@ export const sections: readonly Section[] = [
name: 'action',
in: 'path',
type: 'string',
desc: 'data — return Warp stats. del — delete Warp data. config — return current config. reg — register (sends keys). changeIp — rotate the endpoint. license — set a Warp+ key. interval — set automatic rotation in hours.',
desc: 'data — return Warp stats. del — delete Warp data. config — return current config. reg — register (sends keys). regMasque — register a separate device enrolled for MASQUE and return its warp key pair, addresses and endpoint. changeIp — rotate the endpoint. license — set a Warp+ key. interval — set automatic rotation in hours.',
},
{
name: 'privateKey',

View file

@ -17,6 +17,7 @@ const MULTI_USER_PROTOCOLS = new Set([
'mtproto',
'amneziawg',
'tuic',
'masque',
]);
interface BulkAttachInboundsModalProps {

View file

@ -17,6 +17,7 @@ const MULTI_USER_PROTOCOLS = new Set([
'mtproto',
'amneziawg',
'tuic',
'masque',
]);
interface BulkDetachInboundsModalProps {

View file

@ -39,6 +39,7 @@ const MULTI_CLIENT_PROTOCOLS = new Set([
'wireguard',
'amneziawg',
'tuic',
'masque',
]);
const EMPTY: ClientBulkAddFormValues = {

View file

@ -65,6 +65,7 @@ const MULTI_CLIENT_PROTOCOLS = new Set([
'mtproto',
'amneziawg',
'tuic',
'masque',
]);
const CLIENT_FORM_MODAL_Z_INDEX = 1000;

View file

@ -44,6 +44,7 @@ const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
mixed: 'lime',
tunnel: 'orange',
tuic: 'orange',
masque: 'red',
};
export function sortHosts(hosts: HostRecord[]): HostRecord[] {

View file

@ -326,6 +326,7 @@ export default function InboundsPage() {
switch (dbInbound.protocol) {
case 'trojan':
case 'shadowsocks':
case 'masque':
return c.password === client.password && c.email === client.email;
default:
return c.id === client.id && c.email === client.email;

View file

@ -44,7 +44,11 @@ import { FormField, rhfZodValidate } from '@/components/form/rhf';
import { Protocols, TRAFFIC_RESETS } from '@/schemas/primitives';
import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
import { HysteriaStreamSettingsSchema } from '@/schemas/protocols/stream/hysteria';
import { createHysteriaTlsSettingsWithDefaultCert } from '@/lib/xray/inbound-tls-defaults';
import {
createHysteriaTlsSettingsWithDefaultCert,
createMasqueTlsSettingsWithDefaultCert,
} from '@/lib/xray/inbound-tls-defaults';
import { MASQUE_DEFAULT_PATH } from '@/schemas/protocols/stream/masque';
import { NODE_ELIGIBLE_PROTOCOLS } from '@/lib/xray/node-protocols';
import { VLESS_AUTH_LABEL_KEYS, vlessEncryptionAuthKind } from '@/lib/xray/vless-encryption';
import { SniffingSchema } from '@/schemas/primitives/sniffing';
@ -54,8 +58,10 @@ import { WsStreamSettingsSchema } from '@/schemas/protocols/stream/ws';
import { GrpcStreamSettingsSchema } from '@/schemas/protocols/stream/grpc';
import { HttpUpgradeStreamSettingsSchema } from '@/schemas/protocols/stream/httpupgrade';
import { XHttpStreamSettingsSchema } from '@/schemas/protocols/stream/xhttp';
import { XDriveStreamSettingsSchema } from '@/schemas/protocols/stream/xdrive';
import { DateTimePicker } from '@/components/form';
import { FinalMaskField } from '@/lib/xray/forms/fields';
import XDriveForm from '@/lib/xray/forms/transport/XDriveForm';
import './InboundFormModal.css';
import { AdvancedAllEditor, AdvancedSliceEditor } from './advanced-editors';
@ -64,6 +70,8 @@ import {
AmneziawgFields,
HttpFields,
HysteriaFields,
MasqueSettingsFields,
MasqueStreamFields,
MixedFields,
MtprotoFields,
ShadowsocksFields,
@ -214,6 +222,8 @@ function newStreamSlice(n: string): Record<string, unknown> {
return HttpUpgradeStreamSettingsSchema.parse({});
case 'xhttp':
return XHttpStreamSettingsSchema.parse({});
case 'xdrive':
return XDriveStreamSettingsSchema.parse({});
default:
return {};
}
@ -281,6 +291,7 @@ export default function InboundFormModal({
*/
const hasSelectableTransport =
protocol !== Protocols.HYSTERIA &&
protocol !== Protocols.MASQUE &&
protocol !== Protocols.WIREGUARD &&
protocol !== Protocols.TUNNEL &&
protocol !== Protocols.TUIC;
@ -548,11 +559,18 @@ export default function InboundFormModal({
],
},
});
} else if (next === Protocols.MASQUE) {
setV('streamSettings', {
network: 'masque',
security: 'tls',
masqueSettings: { path: MASQUE_DEFAULT_PATH },
tlsSettings: createMasqueTlsSettingsWithDefaultCert(),
});
} else if (next === Protocols.WIREGUARD || next === Protocols.TUNNEL) {
setV('streamSettings', { security: 'none' });
} else {
const current = getV('streamSettings') as { network?: string } | undefined;
if (current?.network === 'hysteria' || !current?.network) {
if (current?.network === 'hysteria' || current?.network === 'masque' || !current?.network) {
setV('streamSettings', { network: 'tcp', security: 'none', tcpSettings: {} });
}
}
@ -815,6 +833,8 @@ export default function InboundFormModal({
{protocol === Protocols.TUIC && <TuicFields />}
{protocol === Protocols.MASQUE && <MasqueSettingsFields />}
{protocol === Protocols.TUN && <TunFields />}
{protocol === Protocols.TUNNEL && <TunnelFields />}
@ -865,12 +885,22 @@ export default function InboundFormModal({
'grpcSettings',
'httpupgradeSettings',
'xhttpSettings',
'xdriveSettings',
];
const current = (getV('streamSettings') as Record<string, unknown>) ?? {};
const cleaned: Record<string, unknown> = { ...current, network: next };
for (const k of ALL) {
if (k !== `${next}Settings`) delete cleaned[k];
}
if (next === 'xdrive') {
/* The core would read the inbound's TLS as the storage API's client TLS. */
cleaned.security = 'none';
delete cleaned.tlsSettings;
delete cleaned.realitySettings;
} else {
delete cleaned.address;
delete cleaned.port;
}
cleaned[`${next}Settings`] = newStreamSlice(next);
if (next === 'kcp') {
const fm = (cleaned.finalmask as Record<string, unknown> | undefined) ?? {};
@ -894,6 +924,12 @@ export default function InboundFormModal({
cleaned.finalmask = { ...fm, udp };
}
}
/* WebSocket needs HTTP/1.1 for its handshake; the TLS default
* ALPN ['h2','http/1.1'] makes the server negotiate h2 (see #6782). */
if (next === 'ws' && cleaned.security === 'tls') {
const tls = (cleaned.tlsSettings as Record<string, unknown> | undefined) ?? {};
cleaned.tlsSettings = { ...tls, alpn: ['http/1.1'] };
}
setV('streamSettings', cleaned);
};
@ -912,6 +948,7 @@ export default function InboundFormModal({
{ value: 'grpc', label: 'gRPC' },
{ value: 'httpupgrade', label: 'HTTPUpgrade' },
{ value: 'xhttp', label: 'XHTTP' },
{ value: 'xdrive', label: 'XDRIVE' },
]}
/>
</Form.Item>
@ -922,6 +959,8 @@ export default function InboundFormModal({
dropdown is hidden above. */}
{protocol === Protocols.HYSTERIA && <HysteriaFields />}
{protocol === Protocols.MASQUE && <MasqueStreamFields />}
{hasSelectableTransport && (
<>
{network === 'tcp' && <RawForm />}
@ -935,6 +974,8 @@ export default function InboundFormModal({
{network === 'httpupgrade' && <HttpUpgradeForm />}
{network === 'kcp' && <KcpForm />}
{network === 'xdrive' && <XDriveForm />}
</>
)}
@ -957,6 +998,7 @@ export default function InboundFormModal({
onChange={field.onChange}
network={network}
protocol={protocol}
side="server"
/>
)}
/>
@ -966,7 +1008,7 @@ export default function InboundFormModal({
const tlsOk = canEnableTls({ protocol, streamSettings: { network, security } });
const realityOk = canEnableReality({ protocol, streamSettings: { network, security } });
const tlsOnly = protocol === Protocols.HYSTERIA;
const tlsOnly = protocol === Protocols.HYSTERIA || protocol === Protocols.MASQUE;
const securityTab = (
<>
@ -1154,6 +1196,7 @@ export default function InboundFormModal({
Protocols.MTPROTO,
Protocols.AMNEZIAWG,
Protocols.TUIC,
Protocols.MASQUE,
] as string[]
).includes(protocol) || isFallbackHost
? [

View file

@ -9,3 +9,4 @@ export { default as MtprotoFields } from './mtproto';
export { default as VlessFields } from './vless';
export { default as AmneziawgFields } from './amneziawg';
export { default as TuicFields } from './tuic';
export { default as MasqueStreamFields, MasqueSettingsFields } from './masque';

View file

@ -0,0 +1,35 @@
import { useTranslation } from 'react-i18next';
import { Input, InputNumber, Select } from 'antd';
import { FormField } from '@/components/form/rhf';
import { MASQUE_DEFAULT_PATH } from '@/schemas/protocols/stream/masque';
export function MasqueSettingsFields() {
const { t } = useTranslation();
return (
<>
<FormField
label={t('pages.inbounds.form.masque.addressPool')}
tooltip={t('pages.inbounds.form.masque.addressPoolDesc')}
name={['settings', 'address']}
required
>
<Select mode="tags" tokenSeparators={[',', ' ']} open={false} />
</FormField>
<FormField label={t('pages.inbounds.info.mtu')} name={['settings', 'mtu']}>
<InputNumber min={1280} max={65535} placeholder="1280" style={{ width: '100%' }} />
</FormField>
</>
);
}
// The server matches requests on `path` alone; host, credentials and headers are
// the client's, delivered through the JSON subscription.
export default function MasqueStreamFields() {
const { t } = useTranslation();
return (
<FormField label={t('path')} name={['streamSettings', 'masqueSettings', 'path']}>
<Input placeholder={MASQUE_DEFAULT_PATH} />
</FormField>
);
}

View file

@ -1,6 +1,6 @@
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Space, Tooltip } from 'antd';
import { Button, Form, Input, InputNumber, Select, Space, Switch, Tooltip } from 'antd';
import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
import { useFieldArray, useFormContext } from 'react-hook-form';
@ -35,6 +35,12 @@ function StringList({ name, label, placeholder }: StringListProps) {
);
}
// xray-core 26.9.30 accepts exactly these autoSystemWfpBlockLeak values.
const WFP_BLOCK_LEAK_OPTIONS = [
{ value: 'dns', label: 'dns' },
{ value: 'misconfigtun', label: 'misconfigtun' },
];
export default function TunFields() {
const { t } = useTranslation();
return (
@ -42,6 +48,13 @@ export default function TunFields() {
<FormField name={['settings', 'name']} label={t('pages.inbounds.info.interfaceName')}>
<Input placeholder="xray0" />
</FormField>
<FormField
name={['settings', 'desc']}
label={t('pages.inbounds.form.tunDesc')}
tooltip={t('pages.inbounds.form.tunDescTooltip')}
>
<Input id="tun-desc" placeholder="Wintun" />
</FormField>
<FormField name={['settings', 'mtu']} label="MTU">
<InputNumber min={0} />
</FormField>
@ -77,6 +90,26 @@ export default function TunFields() {
>
<Input placeholder="auto" />
</FormField>
<FormField
name={['settings', 'autoSystemDnsToGateway']}
label={t('pages.inbounds.form.autoSystemDnsToGateway')}
tooltip={t('pages.inbounds.form.autoSystemDnsToGatewayTooltip')}
valueProp="checked"
>
<Switch id="tun-auto-dns-gateway" />
</FormField>
<FormField
name={['settings', 'autoSystemWfpBlockLeak']}
label={t('pages.inbounds.form.autoSystemWfpBlockLeak')}
tooltip={t('pages.inbounds.form.autoSystemWfpBlockLeakTooltip')}
>
<Select
id="tun-wfp-block-leak"
mode="multiple"
allowClear
options={WFP_BLOCK_LEAK_OPTIONS}
/>
</FormField>
</>
);
}

View file

@ -344,7 +344,8 @@ export function useSecurityActions({
delete cleaned.tlsSettings;
delete cleaned.realitySettings;
if (next === 'tls') {
cleaned.tlsSettings = createTlsSettingsWithDefaultCert();
const network = (current.network as string | undefined) ?? '';
cleaned.tlsSettings = createTlsSettingsWithDefaultCert(network);
}
if (next === 'reality') {
const reality = RealityStreamSettingsSchema.parse({}) as Record<string, unknown>;

View file

@ -92,6 +92,7 @@ export function isInboundMultiUser(record: { protocol: string; settings: unknown
case 'wireguard':
case 'amneziawg':
case 'tuic':
case 'masque':
return true;
case 'shadowsocks':
return isSSMultiUser({ protocol: 'shadowsocks', settings: readSettings(record.settings) });

View file

@ -68,6 +68,7 @@ const TRACKED_PROTOCOLS: readonly string[] = [
Protocols.MTPROTO,
Protocols.AMNEZIAWG,
Protocols.TUIC,
Protocols.MASQUE,
];
async function fetchSlimInbounds(): Promise<unknown[]> {

View file

@ -24,6 +24,7 @@ const MULTI_CLIENT_PROTOCOLS = new Set([
'trojan',
'hysteria',
'wireguard',
'masque',
]);
const STRATEGY_LABEL_KEYS: Record<SubBalancerStrategy, string> = {

View file

@ -160,6 +160,7 @@ export default function BasicsTab({
);
const routingStrategy = templateSettings?.routing?.domainStrategy ?? 'AsIs';
const routingScript = templateSettings?.routing?.script ?? '';
const log = (templateSettings?.log || {}) as Record<string, unknown>;
const policy = (templateSettings?.policy?.system || {}) as Record<string, boolean>;
const level0 = (templateSettings?.policy?.levels?.['0'] || {}) as Record<string, unknown>;
@ -259,6 +260,25 @@ export default function BasicsTab({
/>
}
/>
<SettingListItem
title={t('pages.xray.routingScript')}
description={t('pages.xray.routingScriptDesc')}
paddings="small"
control={
<Input
value={routingScript}
placeholder="routing.lua"
onChange={(e) => {
const next = e.target.value;
mutate((tt) => {
if (!tt.routing) return;
if (next.trim() === '') delete tt.routing.script;
else tt.routing.script = next;
});
}}
/>
}
/>
<SettingListItem
title={t('pages.xray.outboundTestUrl')}
description={t('pages.xray.outboundTestUrlDesc')}

View file

@ -32,6 +32,7 @@ interface DnsServerModalProps {
const STRATEGIES = DnsQueryStrategySchema.options;
type DnsServerForm = {
id: string;
address: string;
port: number;
domains: string[];
@ -50,6 +51,7 @@ type DnsServerForm = {
function defaultFormValues(): DnsServerForm {
return {
id: '',
address: 'localhost',
port: 53,
domains: [],
@ -83,6 +85,7 @@ function valuesFromServer(server: DnsServerValue | null): DnsServerForm {
skipFallback: data?.skipFallback ?? server.skipFallback ?? false,
disableCache: data?.disableCache ?? server.disableCache ?? false,
finalQuery: data?.finalQuery ?? server.finalQuery ?? false,
id: data?.id ?? server.id ?? '',
tag: data?.tag ?? server.tag ?? '',
clientIP: data?.clientIP ?? server.clientIP ?? '',
serveStale: data?.serveStale ?? server.serveStale ?? false,
@ -101,6 +104,7 @@ function valuesToWire(values: DnsServerForm): DnsServerValue {
values.skipFallback === false &&
values.disableCache === false &&
values.finalQuery === false &&
!values.id &&
!values.tag &&
!values.clientIP &&
values.serveStale === false &&
@ -122,6 +126,7 @@ function valuesToWire(values: DnsServerForm): DnsServerValue {
timeoutMs: values.timeoutMs,
};
if (!isEncryptedDnsAddress(values.address)) out.port = values.port;
if (values.id) out.id = values.id;
if (values.tag) out.tag = values.tag;
if (values.clientIP) out.clientIP = values.clientIP;
return out as DnsServerValue;
@ -183,6 +188,13 @@ export default function DnsServerModal({
<InputNumber min={1} max={65535} />
</FormField>
)}
<FormField
label={t('pages.xray.dns.serverId')}
tooltip={t('pages.xray.dns.serverIdDesc')}
name="id"
>
<Input />
</FormField>
<FormField label={t('pages.xray.dns.tag')} name="tag">
<Input />
</FormField>

View file

@ -297,6 +297,18 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
/>
}
/>
<SettingListItem
paddings="small"
title={t('pages.xray.dns.script')}
description={t('pages.xray.dns.scriptDesc')}
control={
<Input
value={dns?.script ?? ''}
placeholder="dns.lua"
onChange={(e) => setDnsField('script', e.target.value, true)}
/>
}
/>
{(
[
[

View file

@ -8,18 +8,20 @@ import { JsonEditor } from '@/components/form';
import { Wireguard } from '@/utils';
import { formValuesToWirePayload, rawOutboundToFormValues } from '@/lib/xray/outbound-form-adapter';
import { parseOutboundLink } from '@/lib/xray/outbound-link-parser';
import XDriveForm from '@/lib/xray/forms/transport/XDriveForm';
import { XMUX_FRESH_DEFAULTS } from '@/schemas/protocols/stream/xhttp';
import { OutboundFormBaseSchema, type OutboundFormValues } from '@/schemas/forms/outbound-form';
import {
canEnableReality,
canEnableStream,
canEnableTls,
canEnableOutboundTls,
canEnableTlsFlow,
} from '@/lib/xray/protocol-capabilities';
import {
FLOW_OPTIONS,
HYSTERIA_NETWORK_OPTION,
MASQUE_NETWORK_OPTION,
NETWORK_OPTIONS,
PROTOCOL_OPTIONS,
SERVER_PROTOCOLS,
@ -29,6 +31,7 @@ import {
applyNetworkChange,
buildAddModeValues,
hysteriaStreamSlice,
masqueStreamSlice,
newStreamSlice,
} from './outbound-form-helpers';
import {
@ -37,6 +40,7 @@ import {
FreedomFields,
HttpFields,
LoopbackFields,
MasqueFields,
ServerTarget,
ShadowsocksFields,
SocksFields,
@ -51,6 +55,7 @@ import {
HttpUpgradeForm,
HysteriaForm,
KcpForm,
MasqueForm,
MuxForm,
RawForm,
SockoptForm,
@ -106,7 +111,7 @@ export default function OutboundFormModal({
| undefined;
const streamAllowed = canEnableStream({ protocol });
const tlsAllowed = canEnableTls({ protocol, streamSettings: { network, security } });
const tlsAllowed = canEnableOutboundTls({ protocol, streamSettings: { network, security } });
const realityAllowed = canEnableReality({ protocol, streamSettings: { network, security } });
const tlsFlowAllowed = canEnableTlsFlow({ protocol, streamSettings: { network, security } });
@ -161,6 +166,16 @@ export default function OutboundFormModal({
} as StreamValue);
}, [streamAllowed, network, protocol, methods]);
useEffect(() => {
if (protocol !== 'masque') return;
if (network === 'masque' && security === 'tls') return;
const existing = (methods.getValues('streamSettings') ?? {}) as Record<string, unknown>;
const slice = masqueStreamSlice();
if (existing.masqueSettings) slice.masqueSettings = existing.masqueSettings;
if (existing.tlsSettings) slice.tlsSettings = existing.tlsSettings;
methods.setValue('streamSettings', slice as StreamValue);
}, [protocol, network, security, methods]);
useEffect(() => {
if (protocol !== 'hysteria') return;
if (network === 'hysteria' && security === 'tls') return;
@ -193,9 +208,12 @@ export default function OutboundFormModal({
const nextProtocol = methods.getValues('protocol');
const next = rawOutboundToFormValues({ protocol: nextProtocol });
methods.setValue('settings', next.settings);
const currentNetwork = methods.getValues('streamSettings.network') ?? '';
if (nextProtocol === 'hysteria') {
methods.setValue('streamSettings', hysteriaStreamSlice() as StreamValue);
} else if ((methods.getValues('streamSettings.network') ?? '') === 'hysteria') {
} else if (nextProtocol === 'masque') {
methods.setValue('streamSettings', masqueStreamSlice() as StreamValue);
} else if (currentNetwork === 'hysteria' || currentNetwork === 'masque') {
methods.setValue('streamSettings', {
...newStreamSlice('tcp'),
security: 'none',
@ -436,6 +454,7 @@ export default function OutboundFormModal({
{protocol === 'shadowsocks' && <ShadowsocksFields />}
{protocol === 'http' && <HttpFields />}
{protocol === 'socks' && <SocksFields />}
{protocol === 'masque' && <MasqueFields />}
{protocol === 'loopback' && <LoopbackFields />}
{protocol === 'blackhole' && <BlackholeFields />}
@ -469,7 +488,9 @@ export default function OutboundFormModal({
options={
protocol === 'hysteria'
? [HYSTERIA_NETWORK_OPTION]
: NETWORK_OPTIONS
: protocol === 'masque'
? [MASQUE_NETWORK_OPTION]
: NETWORK_OPTIONS
}
/>
</Form.Item>
@ -487,6 +508,10 @@ export default function OutboundFormModal({
{network === 'xhttp' && <XhttpForm onXmuxToggle={onXmuxToggle} />}
{network === 'hysteria' && <HysteriaForm />}
{network === 'xdrive' && <XDriveForm />}
{network === 'masque' && <MasqueForm />}
</>
)}
@ -529,7 +554,7 @@ export default function OutboundFormModal({
buttonStyle="solid"
onChange={(e) => onSecurityChange(e.target.value as string)}
>
{network !== 'hysteria' && (
{network !== 'hysteria' && network !== 'masque' && (
<Radio.Button value="none">{t('none')}</Radio.Button>
)}
{tlsAllowed && <Radio.Button value="tls">TLS</Radio.Button>}

View file

@ -44,12 +44,16 @@ export const NETWORK_OPTIONS: { value: string; label: string }[] = [
{ value: 'grpc', label: 'gRPC' },
{ value: 'httpupgrade', label: 'HTTPUpgrade' },
{ value: 'xhttp', label: 'XHTTP' },
{ value: 'xdrive', label: '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' };
// MASQUE likewise pairs only with its own CONNECT-IP transport.
export const MASQUE_NETWORK_OPTION = { value: 'masque', label: 'MASQUE' };
// Protocols whose form schema carries a flat connect target — these all
// get the shared "server" sub-block (address + port) at the top of the
// protocol section. Wireguard has an address but no port. DNS/freedom/
@ -62,4 +66,5 @@ export const SERVER_PROTOCOLS = new Set<string>([
'socks',
'http',
'hysteria',
'masque',
]);

View file

@ -1,6 +1,7 @@
import { rawOutboundToFormValues } from '@/lib/xray/outbound-form-adapter';
import { canEnableReality, canEnableTls } from '@/lib/xray/protocol-capabilities';
import { canEnableOutboundTls, canEnableReality } from '@/lib/xray/protocol-capabilities';
import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
import { XDriveStreamSettingsSchema } from '@/schemas/protocols/stream/xdrive';
import { MUX_PROTOCOLS } from './outbound-form-constants';
@ -64,6 +65,10 @@ export function newStreamSlice(network: string): Record<string, unknown> {
udpIdleTimeout: 60,
},
};
case 'xdrive':
return { network: 'xdrive', xdriveSettings: XDriveStreamSettingsSchema.parse({}) };
case 'masque':
return { network: 'masque', masqueSettings: {} };
default:
return { network: 'tcp', tcpSettings: { header: { type: 'none' } } };
}
@ -86,6 +91,13 @@ export function hysteriaStreamSlice(): Record<string, unknown> {
};
}
// MASQUE needs TLS (xray-core refuses it otherwise) and dials HTTP/3 unless the ALPN
// offers h2 alone, so it starts on h3 like Hysteria.
export function masqueStreamSlice(): Record<string, unknown> {
const { tlsSettings } = hysteriaStreamSlice();
return { ...newStreamSlice('masque'), security: 'tls', tlsSettings };
}
// Network change cascade: swap the per-network sub-key (tcpSettings,
// wsSettings, etc.) so the DU branch matches. Carry over the security mode
// and its settings (tlsSettings/realitySettings, including SNI serverName)
@ -97,9 +109,10 @@ export function applyNetworkChange(
next: string,
): Record<string, unknown> {
if (next === 'hysteria') return hysteriaStreamSlice();
if (next === 'masque') return masqueStreamSlice();
const stream = prevStream ?? {};
const currentSecurity = (stream.security as string) ?? 'none';
const stillTls = canEnableTls({
const stillTls = canEnableOutboundTls({
protocol,
streamSettings: { network: next, security: currentSecurity },
});

View file

@ -31,7 +31,8 @@ export function outboundAddresses(o: OutboundRow): string[] {
return serverObj ? serverObj.map((s) => `${s.address}:${s.port}`) : [];
}
case isOutboundProtocol(o, Protocols.VLESS):
case isOutboundProtocol(o, Protocols.Hysteria): {
case isOutboundProtocol(o, Protocols.Hysteria):
case isOutboundProtocol(o, Protocols.Masque): {
// A vless row carries either shape, and the probe reads both.
const vnext = settings?.vnext as Array<{ address?: string; port?: number }> | undefined;
const addr = vnext?.[0]?.address || (settings?.address as string | undefined);

View file

@ -11,3 +11,4 @@ export { default as FreedomFields } from './freedom';
export { default as LoopbackFields } from './loopback';
export { default as BlackholeFields } from './blackhole';
export { default as DnsFields } from './dns';
export { default as MasqueFields } from './masque';

View file

@ -0,0 +1,18 @@
import { useTranslation } from 'react-i18next';
import { Select } from 'antd';
import { FormField } from '@/components/form/rhf';
export default function MasqueFields() {
const { t } = useTranslation();
return (
<FormField label={t('pages.xray.outboundForm.remoteDNS')} name={['settings', 'remoteDNS']}>
<Select
mode="tags"
tokenSeparators={[',', ' ']}
open={false}
placeholder="1.1.1.1, 2606:4700:4700::1111"
/>
</FormField>
);
}

View file

@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next';
import { Input, Select } from 'antd';
import { Input, Select, Switch } from 'antd';
import { FormField } from '@/components/form/rhf';
@ -37,6 +37,13 @@ export default function TlsForm() {
>
<Input placeholder="base64 SHA256" />
</FormField>
<FormField
label={t('pages.xray.outboundForm.useSystemCA')}
name={['streamSettings', 'tlsSettings', 'useSystemCA']}
valueProp="checked"
>
<Switch />
</FormField>
</>
);
}

View file

@ -5,5 +5,6 @@ export { default as GrpcForm } from './grpc';
export { default as HttpUpgradeForm } from './httpupgrade';
export { default as XhttpForm } from './xhttp';
export { default as HysteriaForm } from './hysteria';
export { default as MasqueForm } from './masque';
export { default as SockoptForm } from './sockopt';
export { default as MuxForm } from './mux';

View file

@ -0,0 +1,92 @@
import { useTranslation } from 'react-i18next';
import { Form, Input, Select, Switch } from 'antd';
import { useFormContext, useWatch } from 'react-hook-form';
import { HeaderMapEditor } from '@/components/form';
import { FormField } from '@/components/form/rhf';
import { MASQUE_DEFAULT_PATH } from '@/schemas/protocols/stream/masque';
const BASE = ['streamSettings', 'masqueSettings'] as const;
// WARP authenticates with its enrolled key instead of Basic auth, and xray-core
// refuses user/pass next to warp; host and path then default to Cloudflare's endpoint.
export default function MasqueForm() {
const { t } = useTranslation();
const { control, setValue } = useFormContext();
const warp = useWatch({ control, name: 'streamSettings.masqueSettings.warp' });
const warpOn = warp != null;
return (
<>
<FormField label={t('host')} name={[...BASE, 'host']}>
<Input placeholder={warpOn ? 'cloudflareaccess.com' : undefined} />
</FormField>
<FormField label={t('path')} name={[...BASE, 'path']}>
<Input placeholder={warpOn ? '/' : MASQUE_DEFAULT_PATH} />
</FormField>
<Form.Item label="WARP" tooltip={t('pages.xray.outboundForm.masqueWarpDesc')}>
<Switch
checked={warpOn}
onChange={(on) => {
setValue(
'streamSettings.masqueSettings.warp',
on ? { privateKey: '', publicKey: '', address: [] } : undefined,
);
if (on) {
setValue('streamSettings.masqueSettings.user', undefined);
setValue('streamSettings.masqueSettings.pass', undefined);
}
}}
/>
</Form.Item>
{warpOn ? (
<>
<FormField
label={t('pages.xray.warp.privateKey')}
name={[...BASE, 'warp', 'privateKey']}
required
>
<Input.TextArea
autoSize={{ minRows: 2, maxRows: 6 }}
placeholder="-----BEGIN PRIVATE KEY-----"
/>
</FormField>
<FormField
label={t('pages.xray.outboundForm.masqueWarpPublicKey')}
name={[...BASE, 'warp', 'publicKey']}
required
>
<Input.TextArea
autoSize={{ minRows: 2, maxRows: 6 }}
placeholder="-----BEGIN PUBLIC KEY-----"
/>
</FormField>
<FormField
label={t('pages.xray.outboundForm.masqueWarpAddress')}
name={[...BASE, 'warp', 'address']}
required
>
<Select
mode="tags"
tokenSeparators={[',', ' ']}
open={false}
placeholder="172.16.0.2, 2606:4700:110:8a36::2"
/>
</FormField>
</>
) : (
<>
<FormField label={t('username')} name={[...BASE, 'user']}>
<Input />
</FormField>
<FormField label={t('password')} name={[...BASE, 'pass']}>
<Input.Password />
</FormField>
</>
)}
<FormField label={t('pages.inbounds.form.headers')} name={[...BASE, 'headers']}>
<HeaderMapEditor mode="v1" />
</FormField>
</>
);
}

View file

@ -93,6 +93,31 @@ export function buildWarpOutbound(
};
}
interface WarpMasqueRegistration {
privateKey: string;
publicKey: string;
address: string[];
endpoint: string;
}
// Cloudflare serves WARP over MASQUE on HTTP/3 behind this SNI; the enrolled key and
// endpoint key replace user/pass, which xray-core refuses next to a warp block.
export function buildWarpMasqueOutbound(reg: WarpMasqueRegistration): Record<string, unknown> {
return {
tag: 'warp-masque',
protocol: 'masque',
settings: { address: reg.endpoint, port: 443 },
streamSettings: {
network: 'masque',
security: 'tls',
tlsSettings: { serverName: 'consumer-masque.cloudflareclient.com', alpn: ['h3'] },
masqueSettings: {
warp: { privateKey: reg.privateKey, publicKey: reg.publicKey, address: reg.address },
},
},
};
}
export function mergeWarpRotation(
existing: Record<string, unknown> | undefined,
data: WarpData | null,
@ -310,6 +335,21 @@ export default function WarpModal({
}
}
async function addMasqueOutbound() {
setLoading(true);
try {
const msg = await HttpUtil.post<string>('/panel/api/xray/warp/regMasque');
if (!msg?.success || !msg.obj) return;
const outbound = buildWarpMasqueOutbound(JSON.parse(msg.obj) as WarpMasqueRegistration);
const index = templateSettings?.outbounds?.findIndex((o) => o?.tag === 'warp-masque') ?? -1;
if (index >= 0) onResetOutbound({ index, outbound });
else onAddOutbound(outbound);
onClose();
} finally {
setLoading(false);
}
}
function addOutbound() {
if (!stagedOutbound) {
messageApi.warning(t('pages.xray.warp.fetchFirst'));
@ -544,6 +584,18 @@ export default function WarpModal({
)}
</>
)}
<Divider className="my-10">MASQUE</Divider>
<Alert type="info" showIcon title={t('pages.xray.warp.masqueDesc')} />
<Button
type="primary"
className="mt-8"
loading={loading}
icon={<PlusOutlined />}
onClick={addMasqueOutbound}
>
{t('pages.xray.warp.addMasqueOutbound')}
</Button>
</FormProvider>
</Modal>
</>

View file

@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Dropdown, Modal, Space, Table, Tabs, message } from 'antd';
import { Alert, Button, Dropdown, Modal, Space, Table, Tabs, message } from 'antd';
import {
AimOutlined,
ControlOutlined,
@ -64,6 +64,7 @@ export default function RoutingTab({
moved: false,
});
const routingScript = templateSettings?.routing?.script ?? '';
const rules = useMemo(
() => (templateSettings?.routing?.rules || []) as RoutingRule[],
[templateSettings?.routing?.rules],
@ -349,6 +350,14 @@ export default function RoutingTab({
<>
{modalContextHolder}
{messageContextHolder}
{routingScript.trim() !== '' && (
<Alert
type="warning"
showIcon
style={{ marginBottom: 12 }}
title={t('pages.xray.routingScriptActive', { section: t('pages.xray.basicTemplate') })}
/>
)}
<Tabs
defaultActiveKey="basic"
items={[

View file

@ -14,6 +14,7 @@ export function isEncryptedDnsAddress(address: string): boolean {
}
export const DnsServerObjectInnerSchema = z.object({
id: z.string().optional(),
address: z.string(),
port: PortSchema.optional(),
domains: z.array(z.string()).optional(),
@ -54,6 +55,7 @@ export const DnsObjectSchema = z.object({
tag: z.string().optional(),
hosts: DnsHostsSchema.optional(),
servers: z.array(DnsServerEntrySchema).optional(),
script: z.string().optional(),
clientIp: z.string().optional(),
queryStrategy: DnsQueryStrategySchema.default('UseIP'),
disableCache: z.boolean().default(false),

View file

@ -125,6 +125,13 @@ export const HysteriaOutboundFormSettingsSchema = z.object({
});
export type HysteriaOutboundFormSettings = z.infer<typeof HysteriaOutboundFormSettingsSchema>;
export const MasqueOutboundFormSettingsSchema = z.object({
address: z.string().default(''),
port: PortSchema.default(443),
remoteDNS: z.array(z.string()).default([]),
});
export type MasqueOutboundFormSettings = z.infer<typeof MasqueOutboundFormSettingsSchema>;
// FinalRule (freedom): network/port are strings; ip is string[]; blockDelay
// is only meaningful when action === 'block'. The adapter omits empty
// fields from the wire payload.
@ -200,6 +207,7 @@ export const OutboundFormSettingsSchema = z.discriminatedUnion('protocol', [
z.object({ protocol: z.literal('wireguard'), settings: WireguardOutboundFormSettingsSchema }),
z.object({ protocol: z.literal('amneziawg'), settings: AmneziaWGOutboundFormSettingsSchema }),
z.object({ protocol: z.literal('hysteria'), settings: HysteriaOutboundFormSettingsSchema }),
z.object({ protocol: z.literal('masque'), settings: MasqueOutboundFormSettingsSchema }),
z.object({ protocol: z.literal('freedom'), settings: FreedomOutboundFormSettingsSchema }),
z.object({ protocol: z.literal('blackhole'), settings: BlackholeOutboundFormSettingsSchema }),
z.object({ protocol: z.literal('dns'), settings: DnsOutboundFormSettingsSchema }),

View file

@ -19,6 +19,7 @@ export const OutboundProtocols = Object.freeze({
Wireguard: 'wireguard',
AmneziaWG: 'amneziawg',
Hysteria: 'hysteria',
Masque: 'masque',
Socks: 'socks',
HTTP: 'http',
Loopback: 'loopback',

View file

@ -14,6 +14,7 @@ export const ProtocolSchema = z.enum([
'mtproto',
'amneziawg',
'tuic',
'masque',
]);
export type Protocol = z.infer<typeof ProtocolSchema>;
@ -37,4 +38,5 @@ export const Protocols = Object.freeze({
MTPROTO: 'mtproto',
AMNEZIAWG: 'amneziawg',
TUIC: 'tuic',
MASQUE: 'masque',
});

View file

@ -3,6 +3,7 @@ import { z } from 'zod';
import { AmneziawgInboundSettingsSchema } from './amneziawg';
import { HttpInboundSettingsSchema } from './http';
import { HysteriaInboundSettingsSchema } from './hysteria';
import { MasqueInboundSettingsSchema } from './masque';
import { MixedInboundSettingsSchema } from './mixed';
import { MtprotoInboundSettingsSchema } from './mtproto';
import { ShadowsocksInboundSettingsSchema } from './shadowsocks';
@ -17,6 +18,7 @@ import { WireguardInboundSettingsSchema } from './wireguard';
export * from './amneziawg';
export * from './http';
export * from './hysteria';
export * from './masque';
export * from './mixed';
export * from './mtproto';
export * from './shadowsocks';
@ -47,5 +49,6 @@ export const InboundSettingsSchema = z.discriminatedUnion('protocol', [
z.object({ protocol: z.literal('mtproto'), settings: MtprotoInboundSettingsSchema }),
z.object({ protocol: z.literal('amneziawg'), settings: AmneziawgInboundSettingsSchema }),
z.object({ protocol: z.literal('tuic'), settings: TuicInboundSettingsSchema }),
z.object({ protocol: z.literal('masque'), settings: MasqueInboundSettingsSchema }),
]);
export type InboundSettings = z.infer<typeof InboundSettingsSchema>;

View file

@ -0,0 +1,33 @@
import { z } from 'zod';
// MASQUE inbound (CONNECT-IP over HTTP/3 or HTTP/2). Clients log in with their email
// and password over Basic auth; the panel stores `password` and the backend hands it
// to xray-core as `pass`.
export const MasqueClientSchema = z.object({
password: z.string().min(1),
email: z.string().min(1),
limitIp: z.number().int().min(0).default(0),
totalGB: z.number().int().min(0).default(0),
expiryTime: z.number().int().default(0),
enable: z.boolean().default(true),
tgId: z
.union([z.number(), z.string()])
.transform((v) => Number(v) || 0)
.default(0),
subId: z.string().default(''),
comment: z.string().default(''),
reset: z.number().int().min(0).default(0),
created_at: z.number().int().optional(),
updated_at: z.number().int().optional(),
});
export type MasqueClient = z.infer<typeof MasqueClientSchema>;
export const MASQUE_DEFAULT_ADDRESS_POOL = ['10.14.0.1/24', 'fd14::1/64'];
export const MasqueInboundSettingsSchema = z.object({
clients: z.array(MasqueClientSchema).default([]),
// Tunnel addresses are leased from these prefixes: at most one IPv4 and one IPv6.
address: z.array(z.string()).default(MASQUE_DEFAULT_ADDRESS_POOL),
mtu: z.number().int().min(1280).max(65535).optional(),
});
export type MasqueInboundSettings = z.infer<typeof MasqueInboundSettingsSchema>;

View file

@ -2,6 +2,8 @@ import { z } from 'zod';
export const TunInboundSettingsSchema = z.object({
name: z.string().default('xray0'),
// Windows: the tunnel type a new Wintun adapter is created with (core default Wintun).
desc: z.string().optional(),
mtu: z.number().int().min(0).default(1500),
gateway: z.array(z.string()).default([]),
dns: z.array(z.string()).default([]),

View file

@ -6,6 +6,7 @@ import { DNSOutboundSettingsSchema } from './dns';
import { FreedomOutboundSettingsSchema } from './freedom';
import { HttpOutboundSettingsSchema } from './http';
import { HysteriaOutboundSettingsSchema } from './hysteria';
import { MasqueOutboundSettingsSchema } from './masque';
import { LoopbackOutboundSettingsSchema } from './loopback';
import { ShadowsocksOutboundSettingsSchema } from './shadowsocks';
import { SocksOutboundSettingsSchema } from './socks';
@ -20,6 +21,7 @@ export * from './dns';
export * from './freedom';
export * from './http';
export * from './hysteria';
export * from './masque';
export * from './loopback';
export * from './shadowsocks';
export * from './socks';
@ -36,6 +38,7 @@ export const OutboundSettingsSchema = z.discriminatedUnion('protocol', [
z.object({ protocol: z.literal('wireguard'), settings: WireguardOutboundSettingsSchema }),
z.object({ protocol: z.literal('amneziawg'), settings: AmneziaWGOutboundSettingsSchema }),
z.object({ protocol: z.literal('hysteria'), settings: HysteriaOutboundSettingsSchema }),
z.object({ protocol: z.literal('masque'), settings: MasqueOutboundSettingsSchema }),
z.object({ protocol: z.literal('http'), settings: HttpOutboundSettingsSchema }),
z.object({ protocol: z.literal('socks'), settings: SocksOutboundSettingsSchema }),
z.object({ protocol: z.literal('freedom'), settings: FreedomOutboundSettingsSchema }),

View file

@ -0,0 +1,12 @@
import { z } from 'zod';
import { PortSchema } from '@/schemas/primitives';
// MASQUE outbound names its server only; credentials, path and WARP ride on the
// masque transport. remoteDNS lists IPs queried inside the CONNECT-IP tunnel.
export const MasqueOutboundSettingsSchema = z.object({
address: z.string().min(1),
port: PortSchema,
remoteDNS: z.array(z.string()).optional(),
});
export type MasqueOutboundSettings = z.infer<typeof MasqueOutboundSettingsSchema>;

View file

@ -20,7 +20,7 @@ export const TcpMaskSchema = z.object({
export type TcpMask = z.infer<typeof TcpMaskSchema>;
// 'udphop' is client-only in xray-core (it refuses to wrap a server socket),
// so it round-trips here but is deliberately absent from the mask dropdown.
// so only the client-side mask editors offer it.
export const UdpMaskTypeSchema = z.enum([
'salamander',
'mkcp-legacy',

View file

@ -1,14 +1,18 @@
import { z } from 'zod';
import { PortSchema } from '@/schemas/primitives';
import { ExternalProxyEntrySchema } from './external-proxy';
import { FinalMaskStreamSettingsSchema } from './finalmask';
import { GrpcStreamSettingsSchema } from './grpc';
import { HttpUpgradeStreamSettingsSchema } from './httpupgrade';
import { HysteriaStreamSettingsSchema } from './hysteria';
import { KcpStreamSettingsSchema } from './kcp';
import { MasqueStreamSettingsSchema } from './masque';
import { SockoptStreamSettingsSchema } from './sockopt';
import { TcpStreamSettingsSchema } from './tcp';
import { WsStreamSettingsSchema } from './ws';
import { XDriveStreamSettingsSchema } from './xdrive';
import { XHttpStreamSettingsSchema } from './xhttp';
export * from './external-proxy';
@ -17,9 +21,11 @@ export * from './grpc';
export * from './httpupgrade';
export * from './hysteria';
export * from './kcp';
export * from './masque';
export * from './sockopt';
export * from './tcp';
export * from './ws';
export * from './xdrive';
export * from './xhttp';
export const NetworkSchema = z.enum([
@ -30,6 +36,8 @@ export const NetworkSchema = z.enum([
'httpupgrade',
'xhttp',
'hysteria',
'xdrive',
'masque',
]);
export type Network = z.infer<typeof NetworkSchema>;
@ -53,6 +61,15 @@ const TransportNetworkSettingsSchema = z.discriminatedUnion('network', [
}),
z.object({ network: z.literal('xhttp'), xhttpSettings: XHttpStreamSettingsSchema }),
z.object({ network: z.literal('hysteria'), hysteriaSettings: HysteriaStreamSettingsSchema }),
// `masque` pairs only with the masque protocol, in both directions.
z.object({ network: z.literal('masque'), masqueSettings: MasqueStreamSettingsSchema }),
// The stream-level address/port is the domain front XDRIVE dials for its storage API.
z.object({
network: z.literal('xdrive'),
xdriveSettings: XDriveStreamSettingsSchema,
address: z.string().optional(),
port: PortSchema.optional(),
}),
]);
// Wireguard (always a UDP listener) and Tunnel (dokodemo-door) expose no

View file

@ -0,0 +1,26 @@
import { z } from 'zod';
// The path xray-core's MASQUE transport uses when none is set; the server matches
// a request on it after expanding {target}/{ipproto} to "*".
export const MASQUE_DEFAULT_PATH = '/.well-known/masque/ip/*/*/';
// WARP over MASQUE: an enrolled ECDSA P-256 key (PEM or base64 DER), the endpoint's
// public key, and the tunnel addresses Cloudflare assigned (one IPv4, one IPv6).
export const MasqueWarpSchema = z.object({
privateKey: z.string().default(''),
publicKey: z.string().default(''),
address: z.array(z.string()).default([]),
});
export type MasqueWarp = z.infer<typeof MasqueWarpSchema>;
// A server reads only `path`; host, user/pass (Basic auth), headers and warp are the
// client side. Every key is optional so a stored stream round-trips byte-stably.
export const MasqueStreamSettingsSchema = z.object({
host: z.string().optional(),
path: z.string().optional(),
user: z.string().optional(),
pass: z.string().optional(),
headers: z.record(z.string(), z.string()).optional(),
warp: MasqueWarpSchema.optional(),
});
export type MasqueStreamSettings = z.infer<typeof MasqueStreamSettingsSchema>;

View file

@ -0,0 +1,26 @@
import { z } from 'zod';
// XDRIVE tunnels the stream through files in shared cloud storage, so both ends
// carry the same service, folder and secrets. Zero/absent tuning knobs take the
// core's defaults; segmentBytes and concurrency are capped there at 16 MiB / 64.
export const XDriveServiceSchema = z.enum(['Google Drive', 'local', 'template']);
export type XDriveService = z.infer<typeof XDriveServiceSchema>;
const MillisSchema = z.number().int().min(0);
export const XDriveStreamSettingsSchema = z.object({
service: XDriveServiceSchema.default('Google Drive'),
remoteFolder: z.string().default(''),
// Google Drive takes exactly three, in order: ClientID, ClientSecret, RefreshToken.
secrets: z.array(z.string()).default([]),
segmentBytes: z.number().int().min(0).optional(),
flushIntervalMs: MillisSchema.optional(),
pollIntervalMs: MillisSchema.optional(),
maxPollIntervalMs: MillisSchema.optional(),
sessionTtlSeconds: z.number().int().min(0).optional(),
concurrency: z.number().int().min(0).optional(),
eagerWindowMs: MillisSchema.optional(),
holeTimeoutMs: MillisSchema.optional(),
template: z.record(z.string(), z.unknown()).optional(),
});
export type XDriveStreamSettings = z.infer<typeof XDriveStreamSettingsSchema>;

View file

@ -27,6 +27,7 @@ export const XraySettingsValueSchema = z
rules: z.array(RuleObjectSchema).optional(),
balancers: z.array(BalancerObjectSchema).optional(),
domainStrategy: z.string().optional(),
script: z.string().optional(),
})
.loose()
.optional(),

View file

@ -38,6 +38,107 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses combined byte-stably 1`
}
`;
exports[`FinalMaskStreamSettingsSchema fixtures > parses header-custom-variables byte-stably 1`] = `
{
"tcp": [
{
"settings": {
"clients": [
[
{
"capture": "nonce",
"delay": 0,
"rand": 8,
"type": "array",
},
{
"delay": 0,
"packet": "HELLO",
"type": "str",
},
],
],
"errors": [
[
{
"delay": 0,
"packet": "DENIED",
"type": "str",
},
],
],
"servers": [
[
{
"delay": 0,
"reuse": "nonce",
},
{
"delay": 0,
"transform": {
"args": [
{
"bytes": "OK ",
"type": "str",
},
{
"reuse": "nonce",
},
],
"op": "concat",
},
},
],
],
},
"type": "header-custom",
},
],
"udp": [
{
"settings": {
"client": [
{
"capture": "token",
"rand": 8,
"type": "array",
},
],
"mode": "standalone",
"server": [
{
"reuse": "token",
},
],
},
"type": "header-custom",
},
{
"settings": {
"ascii": "prefer_entropy",
"paddingMax": 7,
"paddingMin": 2,
"password": "s3cr3t",
},
"type": "sudoku",
},
{
"settings": {
"noise": [
{
"delay": "10-16",
"rand": "10-20",
"type": "array",
},
],
"reset": "60-120",
},
"type": "noise",
},
],
}
`;
exports[`FinalMaskStreamSettingsSchema fixtures > parses quic-params byte-stably 1`] = `
{
"quicParams": {
@ -274,14 +375,18 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses udp-mask byte-stably 1`
"domains": [
{
"edns0": 1232,
"name": "example.com",
"names": [
"example.com",
],
"types": [
16,
],
},
{
"edns0": 1232,
"name": "example.org",
"names": [
"example.org",
],
"types": [
1,
],
@ -289,10 +394,9 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses udp-mask byte-stably 1`
],
"resolvers": [
{
"settings": {
"addr": "1.1.1.1:53",
},
"type": "udp",
"addrs": [
"udp://1.1.1.1:53",
],
},
],
},

View file

@ -84,6 +84,95 @@ exports[`InboundSchema (full) fixtures > parses hysteria-tls byte-stably 1`] = `
}
`;
exports[`InboundSchema (full) fixtures > parses masque-tls byte-stably 1`] = `
{
"down": 0,
"enable": true,
"expiryTime": 0,
"id": 31,
"listen": "",
"port": 8443,
"protocol": "masque",
"remark": "ivy-masque",
"settings": {
"address": [
"10.14.0.1/24",
"fd14::1/64",
],
"clients": [
{
"comment": "",
"email": "ivy@example.test",
"enable": true,
"expiryTime": 0,
"limitIp": 0,
"password": "masque-pass-ivy",
"reset": 0,
"subId": "masque-001",
"tgId": 0,
"totalGB": 0,
},
],
"mtu": 1400,
},
"shareAddr": "",
"shareAddrStrategy": "node",
"sniffing": {
"destOverride": [
"http",
"tls",
"quic",
"fakedns",
],
"domainsExcluded": [],
"enabled": true,
"ipsExcluded": [],
"metadataOnly": false,
"routeOnly": false,
},
"streamSettings": {
"masqueSettings": {
"path": "/.well-known/masque/ip/*/*/",
},
"network": "masque",
"security": "tls",
"tlsSettings": {
"alpn": [
"h3",
"h2",
],
"certificates": [
{
"buildChain": false,
"certificateFile": "/etc/ssl/certs/masque.crt",
"keyFile": "/etc/ssl/private/masque.key",
"ocspStapling": 0,
"oneTimeLoading": false,
"usage": "encipherment",
},
],
"cipherSuites": "",
"disableSystemRoot": false,
"echServerKeys": "",
"enableSessionResumption": false,
"maxVersion": "1.3",
"minVersion": "1.2",
"rejectUnknownSni": false,
"serverName": "masque.example.test",
"settings": {
"echConfigList": "",
"fingerprint": "chrome",
"pinnedPeerCertSha256": [],
"verifyPeerCertByName": "",
},
},
},
"tag": "inbound-masque",
"total": 0,
"up": 0,
}
`;
exports[`InboundSchema (full) fixtures > parses shadowsocks-tcp-2022 byte-stably 1`] = `
{
"down": 0,

View file

@ -4,6 +4,8 @@ exports[`genHysteriaLink > hysteria-tls: byte-stable 1`] = `"hysteria2://hyst-v1
exports[`genInboundLinks orchestrator > hysteria-tls: byte-stable 1`] = `"hysteria2://hyst-v1-auth-XYZ@override.test:36715?security=tls&fp=chrome&alpn=h3&sni=hysteria.example.test#parity-test"`;
exports[`genInboundLinks orchestrator > masque-tls: byte-stable 1`] = `""`;
exports[`genInboundLinks orchestrator > shadowsocks-tcp-2022: byte-stable 1`] = `"ss://2022-blake3-aes-256-gcm:ZmFrZS1zZXJ2ZXItcGFzc3dvcmQtMDAwMQ%3D%3D:dGVzdC1jbGllbnQtcGFzc3dvcmQtMQ%3D%3D@override.test:8388?type=tcp#parity-test"`;
exports[`genInboundLinks orchestrator > trojan-ws-tls: byte-stable 1`] = `"trojan://trojan-test-pw-XYZ@override.test:443?type=ws&path=%2Ftrojan&host=trojan.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=trojan.example.test#parity-test"`;

View file

@ -35,6 +35,31 @@ exports[`NetworkSettingsSchema fixtures > parses ws-default byte-stably 1`] = `
}
`;
exports[`NetworkSettingsSchema fixtures > parses xdrive-google-drive byte-stably 1`] = `
{
"address": "142.250.0.1",
"network": "xdrive",
"port": 443,
"xdriveSettings": {
"concurrency": 8,
"eagerWindowMs": 2000,
"flushIntervalMs": 20,
"holeTimeoutMs": 30000,
"maxPollIntervalMs": 500,
"pollIntervalMs": 50,
"remoteFolder": "xray-tunnel",
"secrets": [
"000000000000-example.apps.googleusercontent.com",
"GOCSPX-example-client-secret",
"1//example-refresh-token",
],
"segmentBytes": 524288,
"service": "Google Drive",
"sessionTtlSeconds": 300,
},
}
`;
exports[`NetworkSettingsSchema fixtures > parses xhttp-basic byte-stably 1`] = `
{
"network": "xhttp",

View file

@ -43,7 +43,7 @@ function tooltipIconForLabel(label: string): HTMLElement {
}
describe('ClientFormModal credential tooltips', () => {
it('explains that the Password field is only consumed by Trojan/Shadowsocks', async () => {
it('explains which protocols consume the Password field', async () => {
renderModal();
openCredentialsTab();
@ -52,7 +52,7 @@ describe('ClientFormModal credential tooltips', () => {
await waitFor(() => {
expect(document.body.textContent).toContain(
'Used by Trojan, Shadowsocks, and TUIC clients; ignored for VLESS, VMess, Hysteria, and WireGuard.',
'Used by Trojan, Shadowsocks, TUIC, and MASQUE clients; ignored for VLESS, VMess, Hysteria, and WireGuard.',
);
});
});

View file

@ -0,0 +1,45 @@
import { describe, expect, it, vi } from 'vitest';
import { act, fireEvent, screen } from '@testing-library/react';
import DnsServerModal from '@/pages/xray/dns/DnsServerModal';
import { renderWithProviders } from './test-utils';
describe('DnsServerModal', () => {
// A Lua dns.script (xray-core 26.10.10) picks servers by id; the modal rebuilds the
// server on save, and collapsing an otherwise plain one to its address lost the id.
it('keeps the server id through an edit', async () => {
const onConfirm = vi.fn();
renderWithProviders(
<DnsServerModal
open
server={{ address: '1.1.1.1', id: 'cf', timeoutMs: 4000 }}
isEdit
onClose={() => {}}
onConfirm={onConfirm}
/>,
);
const ok = document.querySelector('.ant-modal-footer .ant-btn-primary') as HTMLElement;
await act(async () => {
fireEvent.click(ok);
});
expect(onConfirm).toHaveBeenCalledTimes(1);
expect(onConfirm.mock.calls[0][0]).toMatchObject({ address: '1.1.1.1', id: 'cf' });
});
it('saves an id typed for a plain server', async () => {
const onConfirm = vi.fn();
renderWithProviders(
<DnsServerModal open server="1.1.1.1" isEdit onClose={() => {}} onConfirm={onConfirm} />,
);
fireEvent.change(screen.getByLabelText('ID'), { target: { value: 'cf' } });
const ok = document.querySelector('.ant-modal-footer .ant-btn-primary') as HTMLElement;
await act(async () => {
fireEvent.click(ok);
});
expect(onConfirm.mock.calls[0][0]).toMatchObject({ address: '1.1.1.1', id: 'cf' });
});
});

View file

@ -1,5 +1,5 @@
import { useState } from 'react';
import { describe, expect, it } from 'vitest';
import { useEffect, useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, screen } from '@testing-library/react';
import DnsTab from '@/pages/xray/dns/DnsTab';
@ -127,4 +127,28 @@ describe('DnsTab', () => {
expect(screen.queryByLabelText('Domain (e.g. domain:example.com)')).toBeNull();
});
// xray-core 26.10.10 dns.script is a file path; an empty one fails the core build.
it('writes the Lua script path and drops the key once cleared', () => {
const seen = vi.fn();
const latest = () => seen.mock.lastCall?.[0] as { dns: Record<string, unknown> };
function Harness() {
const [templateSettings, setTemplateSettings] = useState<XraySettingsValue | null>(
withHosts({}),
);
useEffect(() => seen(templateSettings), [templateSettings]);
const updateTemplate: SetTemplate = (next) => {
setTemplateSettings((current) => (typeof next === 'function' ? next(current) : next));
};
return <DnsTab templateSettings={templateSettings} setTemplateSettings={updateTemplate} />;
}
renderWithProviders(<Harness />);
const script = screen.getByRole('textbox', { name: 'Lua Script' });
fireEvent.change(script, { target: { value: '/etc/xray/dns.lua' } });
expect(latest().dns.script).toBe('/etc/xray/dns.lua');
fireEvent.change(script, { target: { value: '' } });
expect(latest().dns).not.toHaveProperty('script');
});
});

View file

@ -0,0 +1,54 @@
{
"tcp": [
{
"type": "header-custom",
"settings": {
"clients": [
[
{ "delay": 0, "type": "array", "rand": 8, "capture": "nonce" },
{ "delay": 0, "type": "str", "packet": "HELLO" }
]
],
"servers": [
[
{ "delay": 0, "reuse": "nonce" },
{
"delay": 0,
"transform": {
"op": "concat",
"args": [{ "type": "str", "bytes": "OK " }, { "reuse": "nonce" }]
}
}
]
],
"errors": [[{ "delay": 0, "type": "str", "packet": "DENIED" }]]
}
}
],
"udp": [
{
"type": "header-custom",
"settings": {
"mode": "standalone",
"client": [{ "type": "array", "rand": 8, "capture": "token" }],
"server": [{ "reuse": "token" }]
}
},
{
"type": "sudoku",
"settings": {
"password": "s3cr3t",
"ascii": "prefer_entropy",
"paddingMin": 2,
"paddingMax": 7
}
},
{
"type": "noise",
"settings": {
"reset": "60-120",
"noise": [{ "type": "array", "rand": "10-20", "delay": "10-16" }]
}
}
]
}

View file

@ -49,10 +49,10 @@
"type": "xdns",
"settings": {
"domains": [
{ "name": "example.com", "types": [16], "edns0": 1232 },
{ "name": "example.org", "types": [1], "edns0": 1232 }
{ "names": ["example.com"], "types": [16], "edns0": 1232 },
{ "names": ["example.org"], "types": [1], "edns0": 1232 }
],
"resolvers": [{ "type": "udp", "settings": { "addr": "1.1.1.1:53" } }]
"resolvers": [{ "addrs": ["udp://1.1.1.1:53"] }]
}
},
{

View file

@ -0,0 +1,70 @@
{
"id": 31,
"up": 0,
"down": 0,
"total": 0,
"remark": "ivy-masque",
"enable": true,
"expiryTime": 0,
"listen": "",
"port": 8443,
"tag": "inbound-masque",
"sniffing": {
"enabled": true,
"destOverride": ["http", "tls", "quic", "fakedns"],
"metadataOnly": false,
"routeOnly": false,
"ipsExcluded": [],
"domainsExcluded": []
},
"protocol": "masque",
"settings": {
"clients": [
{
"password": "masque-pass-ivy",
"email": "ivy@example.test",
"limitIp": 0,
"totalGB": 0,
"expiryTime": 0,
"enable": true,
"tgId": 0,
"subId": "masque-001",
"comment": "",
"reset": 0
}
],
"address": ["10.14.0.1/24", "fd14::1/64"],
"mtu": 1400
},
"streamSettings": {
"network": "masque",
"masqueSettings": {
"path": "/.well-known/masque/ip/*/*/"
},
"security": "tls",
"tlsSettings": {
"serverName": "masque.example.test",
"minVersion": "1.2",
"maxVersion": "1.3",
"cipherSuites": "",
"rejectUnknownSni": false,
"disableSystemRoot": false,
"enableSessionResumption": false,
"certificates": [
{
"certificateFile": "/etc/ssl/certs/masque.crt",
"keyFile": "/etc/ssl/private/masque.key",
"oneTimeLoading": false,
"usage": "encipherment",
"buildChain": false
}
],
"alpn": ["h3", "h2"],
"echServerKeys": "",
"settings": {
"fingerprint": "chrome",
"echConfigList": ""
}
}
}
}

View file

@ -0,0 +1,22 @@
{
"network": "xdrive",
"address": "142.250.0.1",
"port": 443,
"xdriveSettings": {
"service": "Google Drive",
"remoteFolder": "xray-tunnel",
"secrets": [
"000000000000-example.apps.googleusercontent.com",
"GOCSPX-example-client-secret",
"1//example-refresh-token"
],
"segmentBytes": 524288,
"flushIntervalMs": 20,
"pollIntervalMs": 50,
"maxPollIntervalMs": 500,
"sessionTtlSeconds": 300,
"concurrency": 8,
"eagerWindowMs": 2000,
"holeTimeoutMs": 30000
}
}

View file

@ -18,12 +18,16 @@ import {
createDefaultVmessInboundSettings,
createDefaultWireguardInboundSettings,
} from '@/lib/xray/inbound-defaults';
import { createHysteriaTlsSettingsWithDefaultCert } from '@/lib/xray/inbound-tls-defaults';
import {
createHysteriaTlsSettingsWithDefaultCert,
createTlsSettingsWithDefaultCert,
} from '@/lib/xray/inbound-tls-defaults';
import { HttpInboundSettingsSchema } from '@/schemas/protocols/inbound/http';
import {
HysteriaClientSchema,
HysteriaInboundSettingsSchema,
} from '@/schemas/protocols/inbound/hysteria';
import { TlsStreamSettingsSchema } from '@/schemas/protocols/security/tls';
import { MixedInboundSettingsSchema } from '@/schemas/protocols/inbound/mixed';
import {
ShadowsocksClientSchema,
@ -234,3 +238,19 @@ describe('createHysteriaTlsSettingsWithDefaultCert', () => {
]);
});
});
describe('createTlsSettingsWithDefaultCert', () => {
it('keeps the schema ALPN default for non-WebSocket transports', () => {
for (const network of [undefined, '', 'tcp', 'kcp', 'grpc', 'httpupgrade', 'xhttp']) {
const tls = createTlsSettingsWithDefaultCert(network);
expect(tls.alpn).toEqual(['h2', 'http/1.1']);
}
});
it("defaults ALPN to http/1.1 for WebSocket (issue #6782)", () => {
const tls = createTlsSettingsWithDefaultCert('ws');
expect(tls.alpn).toEqual(['http/1.1']);
// The overridden value must still satisfy the TLS settings schema.
expect(TlsStreamSettingsSchema.parse(tls).alpn).toEqual(['http/1.1']);
});
});

View file

@ -5,6 +5,7 @@ import InboundFormModal from '@/pages/inbounds/form/InboundFormModal';
import { DBInbound } from '@/models/dbinbound';
import { ThemeProvider } from '@/hooks/useTheme';
import { HttpUtil } from '@/utils';
import { Protocols } from '@/schemas/primitives';
import {
renderWithProviders,
fieldLabels,
@ -39,6 +40,22 @@ function renderModal() {
);
}
// The protocol dropdown is virtualized, so jsdom renders only its first options; step
// the active option by keyboard from the current protocol in the options' own order.
function chooseVirtualizedProtocol(optionText: string) {
const input = document.getElementById('protocol') as HTMLElement;
const select = input.closest('.ant-select') as HTMLElement;
const current = (select.textContent ?? '').trim();
const order = Object.values(Protocols) as string[];
const steps = order.indexOf(optionText) - order.indexOf(current);
if (order.indexOf(optionText) < 0 || order.indexOf(current) < 0 || steps < 0) {
throw new Error(`cannot step from '${current}' to '${optionText}'`);
}
fireEvent.mouseDown(select.querySelector('.ant-select-selector') ?? select);
for (let i = 0; i < steps; i++) fireEvent.keyDown(input, { key: 'ArrowDown', keyCode: 40 });
fireEvent.keyDown(input, { key: 'Enter', keyCode: 13 });
}
function primaryButton(): HTMLElement {
const button = document.querySelector('.ant-modal-footer .ant-btn-primary');
if (!button) throw new Error('Primary modal button not found');
@ -318,6 +335,181 @@ describe('InboundFormModal', () => {
});
});
// The core reads an XDRIVE inbound's TLS/REALITY as the storage API's client TLS, so
// switching a REALITY inbound to XDRIVE must store security none with its secrets.
it('switches a REALITY inbound to an XDRIVE stream with security none', async () => {
const post = vi.mocked(HttpUtil.post);
post.mockClear();
renderCloneLikeEdit(cloneLikeVlessInbound('example.com:443'));
fireEvent.click(screen.getByRole('tab', { name: 'Stream' }));
const transmission = Array.from(document.querySelectorAll('.ant-form-item')).find(
(el) =>
el.querySelector('.ant-form-item-label label')?.textContent?.trim() === 'Transmission',
);
const select = transmission?.querySelector('.ant-select') as HTMLElement;
fireEvent.mouseDown(select.querySelector('.ant-select-selector') ?? select);
const option = Array.from(document.querySelectorAll('.ant-select-item-option')).find(
(o) => (o.getAttribute('title') ?? o.textContent ?? '').trim() === 'XDRIVE',
) as HTMLElement;
fireEvent.click(option);
for (const [label, value] of [
['Remote folder', 'xray-tunnel'],
['Client ID', 'id.apps.googleusercontent.com'],
['Client secret', 'client-secret'],
['Refresh token', 'refresh-token'],
]) {
fireEvent.change(await screen.findByLabelText(label), { target: { value } });
}
fireEvent.click(primaryButton());
const isUpdate = ([url]: unknown[]) => url === '/panel/api/inbounds/update/42';
await waitFor(() => expect(post.mock.calls.some(isUpdate)).toBe(true));
const payload = post.mock.calls.find(isUpdate)![1] as { streamSettings: string };
const stream = JSON.parse(payload.streamSettings) as Record<string, unknown>;
expect(stream).toMatchObject({
network: 'xdrive',
security: 'none',
xdriveSettings: {
service: 'Google Drive',
remoteFolder: 'xray-tunnel',
secrets: ['id.apps.googleusercontent.com', 'client-secret', 'refresh-token'],
},
});
expect(stream.realitySettings).toBeUndefined();
});
// xray-core runs MASQUE only on its own transport behind TLS, and leases tunnel
// addresses from settings.address; picking the protocol must seed all three.
it('adds a MASQUE inbound on its own transport with TLS and an address pool', async () => {
const post = vi.mocked(HttpUtil.post);
post.mockClear();
renderModal();
chooseVirtualizedProtocol('masque');
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
fireEvent.click(screen.getByRole('tab', { name: 'Security' }));
expect(screen.queryByRole('radio', { name: 'None' })).toBeNull();
fireEvent.change(await screen.findByLabelText('Public Key'), {
target: { value: '/etc/ssl/certs/m.crt' },
});
fireEvent.change(screen.getByLabelText('Private Key'), {
target: { value: '/etc/ssl/private/m.key' },
});
fireEvent.click(primaryButton());
const isAdd = ([url]: unknown[]) => url === '/panel/api/inbounds/add';
await waitFor(() => expect(post.mock.calls.some(isAdd)).toBe(true));
const payload = post.mock.calls.find(isAdd)![1] as {
protocol: string;
settings: string;
streamSettings: string;
};
expect(payload.protocol).toBe('masque');
expect(JSON.parse(payload.settings)).toMatchObject({ address: ['10.14.0.1/24', 'fd14::1/64'] });
expect(JSON.parse(payload.streamSettings)).toMatchObject({
network: 'masque',
security: 'tls',
masqueSettings: { path: '/.well-known/masque/ip/*/*/' },
tlsSettings: { alpn: ['h3'] },
});
});
// xray-core refuses a udphop mask on a listener ("client only"), so an inbound keeps its
// advertised hop range in quicParams.udpHop: the client-side lift must not run here.
it('keeps an inbound hop range in quicParams.udpHop on save', async () => {
const post = vi.mocked(HttpUtil.post);
post.mockClear();
const hysteria = new DBInbound({
id: 43,
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: { quicParams: { udpHop: { ports: '20000-30000', interval: '5-10' } } },
},
sniffing: { enabled: false },
nodeId: null,
shareAddrStrategy: 'listen',
shareAddr: '',
});
renderCloneLikeEdit(hysteria);
// Form watches flush in a macrotask; save only once the hop fields have emitted.
await screen.findByDisplayValue('20000-30000');
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
fireEvent.click(primaryButton());
const isUpdate = ([url]: unknown[]) => url === '/panel/api/inbounds/update/43';
await waitFor(() => expect(post.mock.calls.some(isUpdate)).toBe(true));
const payload = post.mock.calls.find(isUpdate)![1] as { streamSettings: string };
const finalmask = JSON.parse(payload.streamSettings).finalmask as Record<string, unknown>;
expect(finalmask.quicParams).toMatchObject({
udpHop: { ports: '20000-30000', interval: '5-10' },
});
expect(JSON.stringify(finalmask.udp ?? [])).not.toContain('udphop');
});
// 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);
post.mockClear();
const tun = new DBInbound({
id: 44,
port: 0,
listen: '',
protocol: 'tun',
remark: 'tun',
enable: true,
settings: {
name: 'xray0',
mtu: 1500,
gateway: ['10.0.0.1/16'],
dns: ['1.1.1.1'],
userLevel: 0,
autoSystemRoutingTable: ['0.0.0.0/0'],
autoOutboundsInterface: 'auto',
},
streamSettings: {},
sniffing: { enabled: false },
nodeId: null,
shareAddrStrategy: 'listen',
shareAddr: '',
});
renderCloneLikeEdit(tun);
fireEvent.change(document.getElementById('tun-desc')!, { target: { value: 'MyTun' } });
fireEvent.click(document.getElementById('tun-auto-dns-gateway')!);
chooseSelectOption('tun-wfp-block-leak', 'dns');
fireEvent.click(primaryButton());
const isUpdate = ([url]: unknown[]) => url === '/panel/api/inbounds/update/44';
await waitFor(() => expect(post.mock.calls.some(isUpdate)).toBe(true));
const payload = post.mock.calls.find(isUpdate)![1] as { settings: string };
expect(JSON.parse(payload.settings)).toMatchObject({
desc: 'MyTun',
autoSystemDnsToGateway: true,
autoSystemWfpBlockLeak: ['dns'],
});
});
// Clients and enable change through their own endpoints; the server keeps the
// stored ones, so the edit form must neither send nor validate its stale copy.
it('edit save neither sends nor validates the clients it loaded', async () => {

View file

@ -60,12 +60,13 @@ describe('inboundFromDb', () => {
// The settings parse strips keys the schema does not list, so a TUN option xray-core
// 26.9.30 added and an admin entered as JSON would vanish on the next form save.
it('keeps the TUN options xray-core 26.9.30 added', () => {
it('keeps the TUN options xray-core 26.9 added', () => {
const inbound = inboundFromDb({
...BASE_DB_FIELDS,
protocol: 'tun',
settings: {
name: 'xray0',
desc: 'MyTun',
gateway: ['10.0.0.1/16'],
autoSystemDnsToGateway: true,
autoSystemWfpBlockLeak: ['dns', 'misconfigtun'],
@ -73,6 +74,7 @@ describe('inboundFromDb', () => {
streamSettings: '',
});
expect(inbound.settings).toMatchObject({
desc: 'MyTun',
autoSystemDnsToGateway: true,
autoSystemWfpBlockLeak: ['dns', 'misconfigtun'],
});

Some files were not shown because too many files have changed in this diff Show more