mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-21 03:11:08 +00:00
Merge branch 'main' into feat/farhadh/client-most-recent-session-traffic
This commit is contained in:
commit
e5cf73ffcf
162 changed files with 5417 additions and 476 deletions
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
59
docs/content/docs/en/config/masque.mdx
Normal file
59
docs/content/docs/en/config/masque.mdx
Normal 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`.
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
"reality",
|
||||
"amneziawg",
|
||||
"tuic",
|
||||
"masque",
|
||||
"transports",
|
||||
"clients",
|
||||
"subscription",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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` است که
|
||||
|
|
|
|||
60
docs/content/docs/fa/config/masque.mdx
Normal file
60
docs/content/docs/fa/config/masque.mdx
Normal 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` قرار دهید.
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
"ssl-certificates",
|
||||
"inbounds",
|
||||
"reality",
|
||||
"masque",
|
||||
"transports",
|
||||
"clients",
|
||||
"subscription",
|
||||
|
|
|
|||
|
|
@ -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 نمایش میدادند.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
## متعادلکنندهها
|
||||
|
||||
یک **متعادلکننده** خروجیها را با یک **انتخابگر** (پیشوندهای برچسب، از جمله مجموعههای
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
59
docs/content/docs/ru/config/masque.mdx
Normal file
59
docs/content/docs/ru/config/masque.mdx
Normal 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`.
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
"inbounds",
|
||||
"reality",
|
||||
"tuic",
|
||||
"masque",
|
||||
"transports",
|
||||
"clients",
|
||||
"subscription",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
## Балансировщики
|
||||
|
||||
**Балансировщик** группирует исходящие соединения по **селектору** (префиксы
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
55
docs/content/docs/zh/config/masque.mdx
Normal file
55
docs/content/docs/zh/config/masque.mdx
Normal 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`。
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
"ssl-certificates",
|
||||
"inbounds",
|
||||
"reality",
|
||||
"masque",
|
||||
"transports",
|
||||
"clients",
|
||||
"subscription",
|
||||
|
|
|
|||
|
|
@ -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` 混淆。
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
## 均衡器
|
||||
|
||||
**均衡器**通过一个**选择器**(标签前缀,包括来自出站订阅的通配符池)将出站分组,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -363,6 +363,7 @@ export default function CommandPalette() {
|
|||
'shadowsocks',
|
||||
'wireguard',
|
||||
'hysteria',
|
||||
'masque',
|
||||
],
|
||||
icon: <ImportOutlined />,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3108,7 +3108,8 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||
"tun",
|
||||
"mtproto",
|
||||
"amneziawg",
|
||||
"tuic"
|
||||
"tuic",
|
||||
"masque"
|
||||
],
|
||||
"example": "vless",
|
||||
"type": "string"
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
|
|||
148
frontend/src/lib/xray/forms/transport/XDriveForm.tsx
Normal file
148
frontend/src/lib/xray/forms/transport/XDriveForm.tsx
Normal 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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ?? '');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
29
frontend/src/lib/xray/udphop-mask.ts
Normal file
29
frontend/src/lib/xray/udphop-mask.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ const MULTI_USER_PROTOCOLS = new Set([
|
|||
'mtproto',
|
||||
'amneziawg',
|
||||
'tuic',
|
||||
'masque',
|
||||
]);
|
||||
|
||||
interface BulkAttachInboundsModalProps {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ const MULTI_USER_PROTOCOLS = new Set([
|
|||
'mtproto',
|
||||
'amneziawg',
|
||||
'tuic',
|
||||
'masque',
|
||||
]);
|
||||
|
||||
interface BulkDetachInboundsModalProps {
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ const MULTI_CLIENT_PROTOCOLS = new Set([
|
|||
'wireguard',
|
||||
'amneziawg',
|
||||
'tuic',
|
||||
'masque',
|
||||
]);
|
||||
|
||||
const EMPTY: ClientBulkAddFormValues = {
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ const MULTI_CLIENT_PROTOCOLS = new Set([
|
|||
'mtproto',
|
||||
'amneziawg',
|
||||
'tuic',
|
||||
'masque',
|
||||
]);
|
||||
|
||||
const CLIENT_FORM_MODAL_Z_INDEX = 1000;
|
||||
|
|
|
|||
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
? [
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
35
frontend/src/pages/inbounds/form/protocols/masque.tsx
Normal file
35
frontend/src/pages/inbounds/form/protocols/masque.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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) });
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ const TRACKED_PROTOCOLS: readonly string[] = [
|
|||
Protocols.MTPROTO,
|
||||
Protocols.AMNEZIAWG,
|
||||
Protocols.TUIC,
|
||||
Protocols.MASQUE,
|
||||
];
|
||||
|
||||
async function fetchSlimInbounds(): Promise<unknown[]> {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ const MULTI_CLIENT_PROTOCOLS = new Set([
|
|||
'trojan',
|
||||
'hysteria',
|
||||
'wireguard',
|
||||
'masque',
|
||||
]);
|
||||
|
||||
const STRATEGY_LABEL_KEYS: Record<SubBalancerStrategy, string> = {
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{(
|
||||
[
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
18
frontend/src/pages/xray/outbounds/protocols/masque.tsx
Normal file
18
frontend/src/pages/xray/outbounds/protocols/masque.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
92
frontend/src/pages/xray/outbounds/transport/masque.tsx
Normal file
92
frontend/src/pages/xray/outbounds/transport/masque.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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={[
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export const OutboundProtocols = Object.freeze({
|
|||
Wireguard: 'wireguard',
|
||||
AmneziaWG: 'amneziawg',
|
||||
Hysteria: 'hysteria',
|
||||
Masque: 'masque',
|
||||
Socks: 'socks',
|
||||
HTTP: 'http',
|
||||
Loopback: 'loopback',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
33
frontend/src/schemas/protocols/inbound/masque.ts
Normal file
33
frontend/src/schemas/protocols/inbound/masque.ts
Normal 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>;
|
||||
|
|
@ -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([]),
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
12
frontend/src/schemas/protocols/outbound/masque.ts
Normal file
12
frontend/src/schemas/protocols/outbound/masque.ts
Normal 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>;
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
26
frontend/src/schemas/protocols/stream/masque.ts
Normal file
26
frontend/src/schemas/protocols/stream/masque.ts
Normal 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>;
|
||||
26
frontend/src/schemas/protocols/stream/xdrive.ts
Normal file
26
frontend/src/schemas/protocols/stream/xdrive.ts
Normal 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>;
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"`;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
45
frontend/src/test/dns-server-modal.test.tsx
Normal file
45
frontend/src/test/dns-server-modal.test.tsx
Normal 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' });
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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" }]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"] }]
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue