diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3afb3ef9d..2e277f000 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -115,7 +115,7 @@ jobs: cd x-ui/bin # Download dependencies - Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.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" diff --git a/DockerInit.sh b/DockerInit.sh index f488635a2..18fccad21 100755 --- a/DockerInit.sh +++ b/DockerInit.sh @@ -33,7 +33,7 @@ if [ -z "$MTG_MULTI_VER" ]; then fi mkdir -p build/bin cd build/bin -curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.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}" diff --git a/docs/content/docs/en/config/inbounds.mdx b/docs/content/docs/en/config/inbounds.mdx index 30eeafb1f..919eaf3ec 100644 --- a/docs/content/docs/en/config/inbounds.mdx +++ b/docs/content/docs/en/config/inbounds.mdx @@ -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). | Hysteria2 isn't a separate protocol internally — it's the `hysteria` protocol diff --git a/docs/content/docs/en/config/masque.mdx b/docs/content/docs/en/config/masque.mdx new file mode 100644 index 000000000..ec5ce137d --- /dev/null +++ b/docs/content/docs/en/config/masque.mdx @@ -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. | + + + 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. + + +## 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`. diff --git a/docs/content/docs/en/config/meta.json b/docs/content/docs/en/config/meta.json index e11e806f2..c6de02cc0 100644 --- a/docs/content/docs/en/config/meta.json +++ b/docs/content/docs/en/config/meta.json @@ -8,6 +8,7 @@ "reality", "amneziawg", "tuic", + "masque", "transports", "clients", "subscription", diff --git a/docs/content/docs/en/config/transports.mdx b/docs/content/docs/en/config/transports.mdx index 77ed33cf6..fdcd21293 100644 --- a/docs/content/docs/en/config/transports.mdx +++ b/docs/content/docs/en/config/transports.mdx @@ -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). | **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. diff --git a/docs/content/docs/en/operations/outbounds-routing.mdx b/docs/content/docs/en/operations/outbounds-routing.mdx index 41961abae..d25256ef9 100644 --- a/docs/content/docs/en/operations/outbounds-routing.mdx +++ b/docs/content/docs/en/operations/outbounds-routing.mdx @@ -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. + + + 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. + + ## Balancers A **balancer** groups outbounds by a **selector** (tag prefixes, including the diff --git a/docs/content/docs/fa/config/inbounds.mdx b/docs/content/docs/fa/config/inbounds.mdx index 1c1d2f710..d8481eca0 100644 --- a/docs/content/docs/fa/config/inbounds.mdx +++ b/docs/content/docs/fa/config/inbounds.mdx @@ -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). | Hysteria2 در سطح داخلی یک پروتکل جداگانه نیست — همان پروتکل `hysteria` است که diff --git a/docs/content/docs/fa/config/masque.mdx b/docs/content/docs/fa/config/masque.mdx new file mode 100644 index 000000000..9b0feb58e --- /dev/null +++ b/docs/content/docs/fa/config/masque.mdx @@ -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 را سرو می‌کند و هر دو با هم، هر دو را. | + + + MASQUE فرمت لینک اشتراک‌گذاری ندارد، بنابراین inboundهای MASQUE لینک، کد QR یا ورودی + Clash ندارند. کلاینت‌ها کانفیگ آمادهٔ import را از **اشتراک JSON** می‌گیرند: یک outbound + از نوع `masque` که با ایمیل و رمز عبور کلاینت احراز هویت می‌کند. + + +## 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` قرار دهید. diff --git a/docs/content/docs/fa/config/meta.json b/docs/content/docs/fa/config/meta.json index 59fa5ff1a..226febab0 100644 --- a/docs/content/docs/fa/config/meta.json +++ b/docs/content/docs/fa/config/meta.json @@ -6,6 +6,7 @@ "ssl-certificates", "inbounds", "reality", + "masque", "transports", "clients", "subscription", diff --git a/docs/content/docs/fa/config/transports.mdx b/docs/content/docs/fa/config/transports.mdx index c9657a959..79cac589d 100644 --- a/docs/content/docs/fa/config/transports.mdx +++ b/docs/content/docs/fa/config/transports.mdx @@ -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). | 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 نمایش می‌دادند. diff --git a/docs/content/docs/fa/operations/outbounds-routing.mdx b/docs/content/docs/fa/operations/outbounds-routing.mdx index fb9d4ad11..2028346c4 100644 --- a/docs/content/docs/fa/operations/outbounds-routing.mdx +++ b/docs/content/docs/fa/operations/outbounds-routing.mdx @@ -152,6 +152,20 @@ Reset یا حذف، peer مربوط به WireGuard را در حساب PIA باط } ``` +### اسکریپت‌های Lua + +Xray 26.10.10 می‌تواند مسیریابی و DNS را به Lua بسپارد. **پایه ← اسکریپت مسیریابی** +مسیر یک فایل `.lua` را می‌گیرد که `HandleRoute` را تعریف می‌کند (مطلق یا نسبت به +پوشه‌ی `bin` در Xray)؛ **DNS ← اسکریپت Lua** فایلی را می‌گیرد که `HandleDNSQuery` را +تعریف می‌کند، و **شناسه**‌ی هر سرور DNS نامی است که اسکریپت با آن سرور را انتخاب می‌کند. + + + اسکریپت مسیریابی جای **همه‌ی** قوانین مسیریابی را می‌گیرد، از جمله قانون `api` خود + پنل. در اسکریپت، اینباند `api` را به اوت‌باند `api` بفرستید، وگرنه آمار ترافیک و + اعمال زنده‌ی تغییرات کلاینت‌ها متوقف می‌شود. اگر فایل وجود نداشته باشد یا بارگذاری + نشود، Xray اجرا نمی‌شود. + + ## متعادل‌کننده‌ها یک **متعادل‌کننده** خروجی‌ها را با یک **انتخاب‌گر** (پیشوندهای برچسب، از جمله مجموعه‌های diff --git a/docs/content/docs/ru/config/inbounds.mdx b/docs/content/docs/ru/config/inbounds.mdx index 763c76ba5..7ba414240 100644 --- a/docs/content/docs/ru/config/inbounds.mdx +++ b/docs/content/docs/ru/config/inbounds.mdx @@ -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). | Hysteria2 внутренне не является отдельным протоколом — это протокол `hysteria` diff --git a/docs/content/docs/ru/config/masque.mdx b/docs/content/docs/ru/config/masque.mdx new file mode 100644 index 000000000..3872b7233 --- /dev/null +++ b/docs/content/docs/ru/config/masque.mdx @@ -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, вместе — оба. | + + + У MASQUE нет формата ссылок, поэтому для инбаундов MASQUE не создаются ссылка, QR-код и + запись Clash. Клиенты получают готовую к импорту конфигурацию из **JSON-подписки**: + аутбаунд `masque`, который аутентифицируется email и паролем клиента. + + +## Аутбаунд + +Выберите протокол аутбаунда **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`. diff --git a/docs/content/docs/ru/config/meta.json b/docs/content/docs/ru/config/meta.json index 6084601c1..35c0c98f8 100644 --- a/docs/content/docs/ru/config/meta.json +++ b/docs/content/docs/ru/config/meta.json @@ -7,6 +7,7 @@ "inbounds", "reality", "tuic", + "masque", "transports", "clients", "subscription", diff --git a/docs/content/docs/ru/config/transports.mdx b/docs/content/docs/ru/config/transports.mdx index 3d67cc9eb..7b517138b 100644 --- a/docs/content/docs/ru/config/transports.mdx +++ b/docs/content/docs/ru/config/transports.mdx @@ -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). | 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. diff --git a/docs/content/docs/ru/operations/outbounds-routing.mdx b/docs/content/docs/ru/operations/outbounds-routing.mdx index 1a765e514..037318beb 100644 --- a/docs/content/docs/ru/operations/outbounds-routing.mdx +++ b/docs/content/docs/ru/operations/outbounds-routing.mdx @@ -165,6 +165,20 @@ hostname получает уникальный тег `nord-` и не } ``` +### Lua-скрипты + +Xray 26.10.10 умеет передавать маршрутизацию и DNS в Lua. **Основное → Скрипт +маршрутизации** принимает путь к файлу `.lua` с функцией `HandleRoute` (абсолютный +или относительно папки `bin` Xray); **DNS → Lua-скрипт** — файл с функцией +`HandleDNSQuery`, а **ID** DNS-сервера — имя, по которому такой скрипт его выбирает. + + + Скрипт маршрутизации заменяет **все** правила маршрутизации, включая собственное + правило `api` панели. Направляйте в скрипте входящий `api` на исходящий `api`, + иначе остановятся статистика трафика и применение изменений клиентов на лету. Xray не + запустится, если файл отсутствует или не загружается. + + ## Балансировщики **Балансировщик** группирует исходящие соединения по **селектору** (префиксы diff --git a/docs/content/docs/zh/config/inbounds.mdx b/docs/content/docs/zh/config/inbounds.mdx index 5c05fdd30..0f3c0837f 100644 --- a/docs/content/docs/zh/config/inbounds.mdx +++ b/docs/content/docs/zh/config/inbounds.mdx @@ -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)。 | 在内部,Hysteria2 并不是一个独立的协议——它是把传输版本设为 2 的 `hysteria` diff --git a/docs/content/docs/zh/config/masque.mdx b/docs/content/docs/zh/config/masque.mdx new file mode 100644 index 000000000..604b121ea --- /dev/null +++ b/docs/content/docs/zh/config/masque.mdx @@ -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,两者同时设置则都提供。 | + + + MASQUE 没有分享链接格式,因此 MASQUE 入站不生成链接、二维码或 Clash 条目。客户端通过 **JSON 订阅**获取可直接导入的配置: + 一个使用该客户端邮箱和密码认证的 `masque` 出站。 + + +## 出站 + +选择 **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`。 diff --git a/docs/content/docs/zh/config/meta.json b/docs/content/docs/zh/config/meta.json index e61c44b83..63f65bd77 100644 --- a/docs/content/docs/zh/config/meta.json +++ b/docs/content/docs/zh/config/meta.json @@ -6,6 +6,7 @@ "ssl-certificates", "inbounds", "reality", + "masque", "transports", "clients", "subscription", diff --git a/docs/content/docs/zh/config/transports.mdx b/docs/content/docs/zh/config/transports.mdx index a6887cb87..953ffe559 100644 --- a/docs/content/docs/zh/config/transports.mdx +++ b/docs/content/docs/zh/config/transports.mdx @@ -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)。 | **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` 混淆。 diff --git a/docs/content/docs/zh/operations/outbounds-routing.mdx b/docs/content/docs/zh/operations/outbounds-routing.mdx index 3d73a5341..8ebe0addd 100644 --- a/docs/content/docs/zh/operations/outbounds-routing.mdx +++ b/docs/content/docs/zh/operations/outbounds-routing.mdx @@ -140,6 +140,18 @@ Xray 配置中——而不会改动你已保存的模板。这是订阅一*池* } ``` +### Lua 脚本 + +Xray 26.10.10 可以把路由和 DNS 交给 Lua 处理。**基础配置 → 路由脚本** 填写定义了 +`HandleRoute` 的 `.lua` 文件路径(绝对路径或相对于 Xray `bin` 目录);**DNS → Lua +脚本** 填写定义了 `HandleDNSQuery` 的文件,DNS 服务器的 **ID** 就是脚本选择它时使用的名称。 + + + 路由脚本会取代**全部**路由规则,包括面板自带的 `api` 规则。请在脚本中把 `api` 入站 + 发往 `api` 出站,否则流量统计和客户端变更的实时生效都会停止。文件不存在或加载失败时 + Xray 无法启动。 + + ## 均衡器 **均衡器**通过一个**选择器**(标签前缀,包括来自出站订阅的通配符池)将出站分组, diff --git a/docs/public/openapi.json b/docs/public/openapi.json index eb97f3167..36963faa8 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -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" } diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index eb97f3167..36963faa8 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -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" } diff --git a/frontend/src/components/command-palette/CommandPalette.tsx b/frontend/src/components/command-palette/CommandPalette.tsx index 5861356ff..5ee263ce9 100644 --- a/frontend/src/components/command-palette/CommandPalette.tsx +++ b/frontend/src/components/command-palette/CommandPalette.tsx @@ -363,6 +363,7 @@ export default function CommandPalette() { 'shadowsocks', 'wireguard', 'hysteria', + 'masque', ], icon: , }, diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index 4070664c8..86b39b37e 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -3108,7 +3108,8 @@ export const SCHEMAS: Record = { "tun", "mtproto", "amneziawg", - "tuic" + "tuic", + "masque" ], "example": "vless", "type": "string" diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index e14608089..4f811d61b 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -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(), diff --git a/frontend/src/lib/xray/forms/fields/FinalMaskField.tsx b/frontend/src/lib/xray/forms/fields/FinalMaskField.tsx index 0ca265f6f..10a83d690 100644 --- a/frontend/src/lib/xray/forms/fields/FinalMaskField.tsx +++ b/frontend/src/lib/xray/forms/fields/FinalMaskField.tsx @@ -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} /> ); diff --git a/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx b/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx index 4029e62dd..5c45dc81b 100644 --- a/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx +++ b/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx @@ -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 { // 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 { } } +function defaultSudokuSettings(): Record { + return { + password: '', + ascii: '', + customTable: '', + customTables: [], + paddingMin: 0, + paddingMax: 0, + }; +} + function defaultXmcProfile(): Record { return { username: '', uuid: '', texturesValue: '', texturesSignature: '' }; } @@ -194,6 +203,10 @@ function defaultUdpMaskSettings(type: string): Record { 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 | 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({ }} /> - {hasQuicParams && } + {hasQuicParams && ( + + )} )} @@ -444,30 +478,7 @@ function TcpMaskItem({ ); } - if (type === 'sudoku') { - return ( - <> - - - - - - - - - - - + + + + + + + + + + {(['client', 'server'] as const).map((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)} /> @@ -1280,7 +1354,7 @@ function XdnsSettings({ udpFieldName }: { udpFieldName: number }) { size="small" icon={} aria-label={t('add')} - onClick={() => add({ name: '', types: [16], edns0: XDNS_LEGACY_EDNS0 })} + onClick={() => add({ names: [], edns0: XDNS_LEGACY_EDNS0 })} /> {domains.map((domain, di) => ( @@ -1296,15 +1370,20 @@ function XdnsSettings({ udpFieldName }: { udpFieldName: number }) { onKeyDown={activateOnKey(() => remove(domain.name))} /> - - + + + + - - - diff --git a/frontend/src/pages/xray/routing/RoutingTab.tsx b/frontend/src/pages/xray/routing/RoutingTab.tsx index 946b5b2c7..e62f2a33e 100644 --- a/frontend/src/pages/xray/routing/RoutingTab.tsx +++ b/frontend/src/pages/xray/routing/RoutingTab.tsx @@ -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() !== '' && ( + + )} ; +export const MasqueOutboundFormSettingsSchema = z.object({ + address: z.string().default(''), + port: PortSchema.default(443), + remoteDNS: z.array(z.string()).default([]), +}); +export type MasqueOutboundFormSettings = z.infer; + // 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 }), diff --git a/frontend/src/schemas/primitives/outbound-protocol.ts b/frontend/src/schemas/primitives/outbound-protocol.ts index 34c60414a..a835aeee1 100644 --- a/frontend/src/schemas/primitives/outbound-protocol.ts +++ b/frontend/src/schemas/primitives/outbound-protocol.ts @@ -19,6 +19,7 @@ export const OutboundProtocols = Object.freeze({ Wireguard: 'wireguard', AmneziaWG: 'amneziawg', Hysteria: 'hysteria', + Masque: 'masque', Socks: 'socks', HTTP: 'http', Loopback: 'loopback', diff --git a/frontend/src/schemas/primitives/protocol.ts b/frontend/src/schemas/primitives/protocol.ts index 461286614..0f813476f 100644 --- a/frontend/src/schemas/primitives/protocol.ts +++ b/frontend/src/schemas/primitives/protocol.ts @@ -14,6 +14,7 @@ export const ProtocolSchema = z.enum([ 'mtproto', 'amneziawg', 'tuic', + 'masque', ]); export type Protocol = z.infer; @@ -37,4 +38,5 @@ export const Protocols = Object.freeze({ MTPROTO: 'mtproto', AMNEZIAWG: 'amneziawg', TUIC: 'tuic', + MASQUE: 'masque', }); diff --git a/frontend/src/schemas/protocols/inbound/index.ts b/frontend/src/schemas/protocols/inbound/index.ts index c95cecf0d..16d63a575 100644 --- a/frontend/src/schemas/protocols/inbound/index.ts +++ b/frontend/src/schemas/protocols/inbound/index.ts @@ -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; diff --git a/frontend/src/schemas/protocols/inbound/masque.ts b/frontend/src/schemas/protocols/inbound/masque.ts new file mode 100644 index 000000000..5de9b1a98 --- /dev/null +++ b/frontend/src/schemas/protocols/inbound/masque.ts @@ -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; + +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; diff --git a/frontend/src/schemas/protocols/inbound/tun.ts b/frontend/src/schemas/protocols/inbound/tun.ts index 580437e51..f9d421c30 100644 --- a/frontend/src/schemas/protocols/inbound/tun.ts +++ b/frontend/src/schemas/protocols/inbound/tun.ts @@ -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([]), diff --git a/frontend/src/schemas/protocols/outbound/index.ts b/frontend/src/schemas/protocols/outbound/index.ts index bb9f470c5..8e10272d0 100644 --- a/frontend/src/schemas/protocols/outbound/index.ts +++ b/frontend/src/schemas/protocols/outbound/index.ts @@ -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 }), diff --git a/frontend/src/schemas/protocols/outbound/masque.ts b/frontend/src/schemas/protocols/outbound/masque.ts new file mode 100644 index 000000000..9abbba5e2 --- /dev/null +++ b/frontend/src/schemas/protocols/outbound/masque.ts @@ -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; diff --git a/frontend/src/schemas/protocols/stream/finalmask.ts b/frontend/src/schemas/protocols/stream/finalmask.ts index 874e9be3d..3cc880d3b 100644 --- a/frontend/src/schemas/protocols/stream/finalmask.ts +++ b/frontend/src/schemas/protocols/stream/finalmask.ts @@ -20,7 +20,7 @@ export const TcpMaskSchema = z.object({ export type TcpMask = z.infer; // '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', diff --git a/frontend/src/schemas/protocols/stream/index.ts b/frontend/src/schemas/protocols/stream/index.ts index dd9833775..5c0cfb69a 100644 --- a/frontend/src/schemas/protocols/stream/index.ts +++ b/frontend/src/schemas/protocols/stream/index.ts @@ -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; @@ -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 diff --git a/frontend/src/schemas/protocols/stream/masque.ts b/frontend/src/schemas/protocols/stream/masque.ts new file mode 100644 index 000000000..b19eb5c84 --- /dev/null +++ b/frontend/src/schemas/protocols/stream/masque.ts @@ -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; + +// 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; diff --git a/frontend/src/schemas/protocols/stream/xdrive.ts b/frontend/src/schemas/protocols/stream/xdrive.ts new file mode 100644 index 000000000..f3387b5f6 --- /dev/null +++ b/frontend/src/schemas/protocols/stream/xdrive.ts @@ -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; + +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; diff --git a/frontend/src/schemas/xray.ts b/frontend/src/schemas/xray.ts index 6738d88bb..a4498d01f 100644 --- a/frontend/src/schemas/xray.ts +++ b/frontend/src/schemas/xray.ts @@ -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(), diff --git a/frontend/src/test/__snapshots__/finalmask.test.ts.snap b/frontend/src/test/__snapshots__/finalmask.test.ts.snap index b401c872a..7b52b03df 100644 --- a/frontend/src/test/__snapshots__/finalmask.test.ts.snap +++ b/frontend/src/test/__snapshots__/finalmask.test.ts.snap @@ -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", + ], }, ], }, diff --git a/frontend/src/test/__snapshots__/inbound-full.test.ts.snap b/frontend/src/test/__snapshots__/inbound-full.test.ts.snap index 453873ed0..0377c41d8 100644 --- a/frontend/src/test/__snapshots__/inbound-full.test.ts.snap +++ b/frontend/src/test/__snapshots__/inbound-full.test.ts.snap @@ -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, diff --git a/frontend/src/test/__snapshots__/inbound-link.test.ts.snap b/frontend/src/test/__snapshots__/inbound-link.test.ts.snap index b844d9f4c..75d6535f0 100644 --- a/frontend/src/test/__snapshots__/inbound-link.test.ts.snap +++ b/frontend/src/test/__snapshots__/inbound-link.test.ts.snap @@ -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"`; diff --git a/frontend/src/test/__snapshots__/stream.test.ts.snap b/frontend/src/test/__snapshots__/stream.test.ts.snap index 6b9adcfa2..17bfef5b8 100644 --- a/frontend/src/test/__snapshots__/stream.test.ts.snap +++ b/frontend/src/test/__snapshots__/stream.test.ts.snap @@ -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", diff --git a/frontend/src/test/client-form-modal.test.tsx b/frontend/src/test/client-form-modal.test.tsx index 460d0d5e1..9cd9c47cc 100644 --- a/frontend/src/test/client-form-modal.test.tsx +++ b/frontend/src/test/client-form-modal.test.tsx @@ -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.', ); }); }); diff --git a/frontend/src/test/dns-server-modal.test.tsx b/frontend/src/test/dns-server-modal.test.tsx new file mode 100644 index 000000000..433ee06ab --- /dev/null +++ b/frontend/src/test/dns-server-modal.test.tsx @@ -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( + {}} + 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( + {}} 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' }); + }); +}); diff --git a/frontend/src/test/dns-tab.test.tsx b/frontend/src/test/dns-tab.test.tsx index b13af098f..1a9c6648c 100644 --- a/frontend/src/test/dns-tab.test.tsx +++ b/frontend/src/test/dns-tab.test.tsx @@ -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 }; + function Harness() { + const [templateSettings, setTemplateSettings] = useState( + withHosts({}), + ); + useEffect(() => seen(templateSettings), [templateSettings]); + const updateTemplate: SetTemplate = (next) => { + setTemplateSettings((current) => (typeof next === 'function' ? next(current) : next)); + }; + return ; + } + + renderWithProviders(); + 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'); + }); }); diff --git a/frontend/src/test/golden/fixtures/finalmask/header-custom-variables.json b/frontend/src/test/golden/fixtures/finalmask/header-custom-variables.json new file mode 100644 index 000000000..ac17cbb3b --- /dev/null +++ b/frontend/src/test/golden/fixtures/finalmask/header-custom-variables.json @@ -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" }] + } + } + ] +} diff --git a/frontend/src/test/golden/fixtures/finalmask/udp-mask.json b/frontend/src/test/golden/fixtures/finalmask/udp-mask.json index 67f7f76c7..a477f996e 100644 --- a/frontend/src/test/golden/fixtures/finalmask/udp-mask.json +++ b/frontend/src/test/golden/fixtures/finalmask/udp-mask.json @@ -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"] }] } }, { diff --git a/frontend/src/test/golden/fixtures/inbound-full/masque-tls.json b/frontend/src/test/golden/fixtures/inbound-full/masque-tls.json new file mode 100644 index 000000000..2e8a5393c --- /dev/null +++ b/frontend/src/test/golden/fixtures/inbound-full/masque-tls.json @@ -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": "" + } + } + } +} diff --git a/frontend/src/test/golden/fixtures/stream/xdrive-google-drive.json b/frontend/src/test/golden/fixtures/stream/xdrive-google-drive.json new file mode 100644 index 000000000..966ce4334 --- /dev/null +++ b/frontend/src/test/golden/fixtures/stream/xdrive-google-drive.json @@ -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 + } +} diff --git a/frontend/src/test/inbound-defaults.test.ts b/frontend/src/test/inbound-defaults.test.ts index 1422e6ea2..7f8f72ea4 100644 --- a/frontend/src/test/inbound-defaults.test.ts +++ b/frontend/src/test/inbound-defaults.test.ts @@ -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']); + }); +}); diff --git a/frontend/src/test/inbound-form-modal.test.tsx b/frontend/src/test/inbound-form-modal.test.tsx index 93351dd26..1592133d5 100644 --- a/frontend/src/test/inbound-form-modal.test.tsx +++ b/frontend/src/test/inbound-form-modal.test.tsx @@ -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; + 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; + 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 () => { diff --git a/frontend/src/test/inbound-from-db.test.ts b/frontend/src/test/inbound-from-db.test.ts index 51aedbb08..759a92fe0 100644 --- a/frontend/src/test/inbound-from-db.test.ts +++ b/frontend/src/test/inbound-from-db.test.ts @@ -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'], }); diff --git a/frontend/src/test/inbound-link.test.ts b/frontend/src/test/inbound-link.test.ts index 084765cb6..745bdf7a8 100644 --- a/frontend/src/test/inbound-link.test.ts +++ b/frontend/src/test/inbound-link.test.ts @@ -127,6 +127,28 @@ describe('genVlessLink', () => { } }); +describe('XDRIVE share links', () => { + // No client reads an xdrive link, and one carrying the storage secrets would print + // them into every QR code; mirrors the Go generator, which emits none either. + it('emits no link for an XDRIVE stream', () => { + const [, raw] = fixturesForProtocol('vless').find(([name]) => name === 'vless-ws-tls')!; + const inbound = InboundSchema.parse({ + ...raw, + streamSettings: { + network: 'xdrive', + security: 'none', + xdriveSettings: { service: 'local', remoteFolder: '/srv/xdrive' }, + }, + }); + const client = (raw as { settings: { clients: Array<{ id: string; email: string }> } }).settings + .clients[0]; + + const entries = genAllLinks({ inbound, client, fallbackHostname: 'panel.example.test' }); + + expect(entries.map((e) => e.link)).toEqual(['']); + }); +}); + describe('applyVlessRoute', () => { const id = '11111111-2222-4333-8444-555555555555'; it('encodes a single value into the 3rd group and no-ops on invalid input', () => { diff --git a/frontend/src/test/inbound-tag.test.ts b/frontend/src/test/inbound-tag.test.ts index 4c2a77338..d2c1826c9 100644 --- a/frontend/src/test/inbound-tag.test.ts +++ b/frontend/src/test/inbound-tag.test.ts @@ -31,6 +31,27 @@ describe('composeInboundTag transport suffix parity', () => { 'in-443-udp', ], ['wireguard forced udp', base({ protocol: 'wireguard' }), 'in-443-udp'], + [ + 'masque without alpn is udp', + base({ protocol: 'masque', streamSettings: { network: 'masque', tlsSettings: {} } }), + 'in-443-udp', + ], + [ + 'masque h2 is tcp', + base({ + protocol: 'masque', + streamSettings: { network: 'masque', tlsSettings: { alpn: ['h2'] } }, + }), + 'in-443-tcp', + ], + [ + 'masque h3+h2 is both', + base({ + protocol: 'masque', + streamSettings: { network: 'masque', tlsSettings: { alpn: ['h3', 'h2'] } }, + }), + 'in-443-tcpudp', + ], [ 'tuic forced udp', base({ protocol: 'tuic', streamSettings: { network: 'tcp' } }), diff --git a/frontend/src/test/outbound-form-modal.test.tsx b/frontend/src/test/outbound-form-modal.test.tsx index 359cf676f..249d7f3fc 100644 --- a/frontend/src/test/outbound-form-modal.test.tsx +++ b/frontend/src/test/outbound-form-modal.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; -import { act, fireEvent, render } from '@testing-library/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import { QueryClientProvider } from '@tanstack/react-query'; import { ThemeProvider } from '@/hooks/useTheme'; @@ -24,13 +24,29 @@ function renderModal(outbound: Record | null = null) { ); } -function toggleSockoptsSwitch() { +function formItemByLabel(label: string): Element | undefined { + return Array.from(document.querySelectorAll('.ant-form-item')).find( + (el) => (el.querySelector('.ant-form-item-label label')?.textContent ?? '').trim() === label, + ); +} + +function chooseOptionByLabel(label: string, optionText: string) { + const select = formItemByLabel(label)?.querySelector('.ant-select'); + if (!select) throw new Error(`${label} select not found`); + 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() === optionText, + ); + if (!option) throw new Error(`Option '${optionText}' not found for ${label}`); + fireEvent.click(option); +} + +function toggleSwitch(label: string) { const item = Array.from(document.querySelectorAll('.ant-form-item')).find( - (el) => - (el.querySelector('.ant-form-item-label label')?.textContent ?? '').trim() === 'Sockopts', + (el) => (el.querySelector('.ant-form-item-label label')?.textContent ?? '').trim() === label, ); const control = item?.querySelector('.ant-switch'); - if (!control) throw new Error('Sockopts switch not found'); + if (!control) throw new Error(`${label} switch not found`); fireEvent.click(control); } @@ -74,7 +90,7 @@ describe('OutboundFormModal', () => { // sockopt.domainStrategy, so freedom must show only one control for it. it('hides the Transport sockopt strategy for freedom', () => { renderModal({ protocol: 'freedom', tag: 'direct', settings: {} }); - toggleSockoptsSwitch(); + toggleSwitch('Sockopts'); expect(fieldLabels()).toContain('Sockopts'); expect(fieldLabels()).not.toContain('Domain Strategy'); @@ -83,7 +99,7 @@ describe('OutboundFormModal', () => { it('keeps the Transport sockopt strategy for protocols without a card field', () => { renderModal({ protocol: 'vless', tag: 'proxy', settings: {} }); - toggleSockoptsSwitch(); + toggleSwitch('Sockopts'); expect(fieldLabels()).toContain('Domain Strategy'); }); @@ -153,9 +169,9 @@ describe('OutboundFormModal', () => { expect(payload.settings.reverse?.tag).toBe('r1'); }); - // xray-core 26.9.30 no longer parses xdns's string lists, so the mask editor lifts - // them into objects on open rather than saving a config the core would refuse. - it('saves a legacy xdns mask in the object shape', async () => { + // xray-core 26.10.10 reads a 26.9.30 xdns mask as no domain at all, so the mask editor + // lifts it to names/addrs on open and must keep those keys registered through the save. + it('saves a legacy xdns mask in the names/addrs shape', async () => { const onConfirm = vi.fn(); const queryClient = makeTestQueryClient(); const outbound = { @@ -175,7 +191,15 @@ describe('OutboundFormModal', () => { security: 'none', kcpSettings: { mtu: 130, tti: 50 }, finalmask: { - udp: [{ type: 'xdns', settings: { resolvers: ['t.example.com+udp://8.8.8.8:53'] } }], + udp: [ + { + type: 'xdns', + settings: { + domains: [{ name: 't.example.com', types: [16], edns0: 1232 }], + resolvers: [{ type: 'udp', settings: { addr: '8.8.8.8:53' } }], + }, + }, + ], }, }, }; @@ -216,10 +240,485 @@ describe('OutboundFormModal', () => { { type: 'xdns', settings: { - domains: [{ name: 't.example.com', types: [16], edns0: 1232 }], - resolvers: [{ type: 'udp', settings: { addr: '8.8.8.8:53' } }], + domains: [{ names: ['t.example.com'], types: [16], edns0: 1232 }], + resolvers: [{ addrs: ['udp://8.8.8.8:53'] }], }, }, ]); }); + + // xray-core 26.10.10 verifies TLS against its bundled roots, not the OS store, so an + // outbound to a server signed by a privately installed CA needs useSystemCA set. + it('saves useSystemCA from the TLS switch', async () => { + const onConfirm = vi.fn(); + const queryClient = makeTestQueryClient(); + const outbound = { + protocol: 'vless', + tag: 'tls-out', + settings: { + vnext: [ + { + address: 'example.com', + port: 443, + users: [{ id: 'c9f0c2d0-0000-4000-8000-000000000000', encryption: 'none' }], + }, + ], + }, + streamSettings: { + network: 'tcp', + security: 'tls', + tlsSettings: { serverName: 'example.com' }, + }, + }; + const tree = (open: boolean) => ( + + + {}} + onConfirm={onConfirm} + /> + + + ); + const { rerender } = render(tree(false)); + rerender(tree(true)); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + toggleSwitch('Use system CA'); + const ok = document.querySelector('.ant-modal-footer .ant-btn-primary') as HTMLElement; + await act(async () => { + fireEvent.click(ok); + }); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + expect(onConfirm).toHaveBeenCalledTimes(1); + const payload = onConfirm.mock.calls[0][0] as { + streamSettings: { tlsSettings: { serverName?: string; useSystemCA?: boolean } }; + }; + expect(payload.streamSettings.tlsSettings).toMatchObject({ + serverName: 'example.com', + useSystemCA: true, + }); + }); + + // xray-core reads Google Drive's three secrets by position (ClientID, ClientSecret, + // RefreshToken); a misordered list fails OAuth only when the tunnel first dials. + it('saves an XDRIVE stream picked in the transmission selector', async () => { + const onConfirm = vi.fn(); + const queryClient = makeTestQueryClient(); + const outbound = { + protocol: 'vless', + tag: 'drive-out', + settings: { + vnext: [ + { + address: 'example.com', + port: 443, + users: [{ id: 'c9f0c2d0-0000-4000-8000-000000000000', encryption: 'none' }], + }, + ], + }, + streamSettings: { network: 'tcp', security: 'none' }, + }; + const tree = (open: boolean) => ( + + + {}} + onConfirm={onConfirm} + /> + + + ); + const { rerender } = render(tree(false)); + rerender(tree(true)); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + chooseOptionByLabel('Transmission', 'XDRIVE'); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + 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(screen.getByLabelText(label), { target: { value } }); + } + const ok = document.querySelector('.ant-modal-footer .ant-btn-primary') as HTMLElement; + await act(async () => { + fireEvent.click(ok); + }); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + expect(onConfirm).toHaveBeenCalledTimes(1); + const payload = onConfirm.mock.calls[0][0] as { streamSettings: Record }; + expect(payload.streamSettings).toMatchObject({ + network: 'xdrive', + xdriveSettings: { + service: 'Google Drive', + remoteFolder: 'xray-tunnel', + secrets: ['id.apps.googleusercontent.com', 'client-secret', 'refresh-token'], + }, + }); + }); +}); + +// xray-core 26.10.10 dials the XDRIVE storage API with the outbound's tlsSettings (SNI, +// uTLS fingerprint), so switching a TLS outbound to XDRIVE must keep them. +describe('OutboundFormModal XDRIVE TLS', () => { + it('keeps TLS when a TLS outbound moves to XDRIVE', async () => { + const onConfirm = vi.fn(); + const queryClient = makeTestQueryClient(); + const outbound = { + protocol: 'vless', + tag: 'drive-out', + settings: { + vnext: [ + { + address: 'www.google.com', + port: 443, + users: [{ id: 'c9f0c2d0-0000-4000-8000-000000000000', encryption: 'none' }], + }, + ], + }, + streamSettings: { + network: 'tcp', + security: 'tls', + tlsSettings: { serverName: 'www.google.com', fingerprint: 'chrome' }, + }, + }; + const tree = (open: boolean) => ( + + + {}} + onConfirm={onConfirm} + /> + + + ); + const { rerender } = render(tree(false)); + rerender(tree(true)); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + chooseOptionByLabel('Transmission', 'XDRIVE'); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + expect((screen.getByRole('radio', { name: 'TLS' }) as HTMLInputElement).checked).toBe(true); + 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(screen.getByLabelText(label), { target: { value } }); + } + const ok = document.querySelector('.ant-modal-footer .ant-btn-primary') as HTMLElement; + await act(async () => { + fireEvent.click(ok); + }); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + expect(onConfirm).toHaveBeenCalledTimes(1); + const payload = onConfirm.mock.calls[0][0] as { streamSettings: Record }; + expect(payload.streamSettings).toMatchObject({ + network: 'xdrive', + security: 'tls', + tlsSettings: { serverName: 'www.google.com', fingerprint: 'chrome' }, + }); + }); +}); + +describe('OutboundFormModal MASQUE', () => { + // xray-core refuses user/pass next to warp, and builds the WARP client certificate + // from privateKey; a save that kept stale credentials or dropped a key never connects. + it('saves a WARP-over-MASQUE outbound without Basic-auth credentials', async () => { + const onConfirm = vi.fn(); + const queryClient = makeTestQueryClient(); + const outbound = { + protocol: 'masque', + tag: 'warp-masque', + settings: { address: '162.159.198.1', port: 443 }, + streamSettings: { + network: 'masque', + security: 'tls', + tlsSettings: { serverName: '' }, + masqueSettings: { user: 'old-user', pass: 'old-pass' }, + }, + }; + const tree = (open: boolean) => ( + + + {}} + onConfirm={onConfirm} + /> + + + ); + const { rerender } = render(tree(false)); + rerender(tree(true)); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + toggleSwitch('WARP'); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + fireEvent.change(screen.getByLabelText('Private key'), { target: { value: 'PRIVATE-PEM' } }); + fireEvent.change(screen.getByLabelText('Endpoint public key'), { + target: { value: 'PUBLIC-PEM' }, + }); + const ok = document.querySelector('.ant-modal-footer .ant-btn-primary') as HTMLElement; + await act(async () => { + fireEvent.click(ok); + }); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + expect(onConfirm).toHaveBeenCalledTimes(1); + const payload = onConfirm.mock.calls[0][0] as { + protocol: string; + settings: Record; + streamSettings: { + network: string; + security: string; + masqueSettings: Record; + }; + }; + expect(payload.protocol).toBe('masque'); + expect(payload.settings).toMatchObject({ address: '162.159.198.1', port: 443 }); + expect(payload.streamSettings).toMatchObject({ network: 'masque', security: 'tls' }); + expect(payload.streamSettings.masqueSettings.warp).toMatchObject({ + privateKey: 'PRIVATE-PEM', + publicKey: 'PUBLIC-PEM', + }); + expect(payload.streamSettings.masqueSettings.user).toBeUndefined(); + expect(payload.streamSettings.masqueSettings.pass).toBeUndefined(); + }); +}); + +describe('OutboundFormModal UDP hop', () => { + // xray-core 26.9.9 ignores quicParams.udpHop; a client hops only through the udphop + // mask, so the editor must lift the dead key and keep every mask field on save. + it('saves a legacy quicParams.udpHop hysteria outbound as a udphop mask', async () => { + const onConfirm = vi.fn(); + const queryClient = makeTestQueryClient(); + const outbound = { + protocol: 'hysteria', + tag: 'hy-out', + settings: { address: 'hy.example.com', port: 443, version: 2 }, + streamSettings: { + network: 'hysteria', + security: 'tls', + hysteriaSettings: { version: 2, auth: 'a', udpIdleTimeout: 60 }, + tlsSettings: { serverName: 'hy.example.com', alpn: ['h3'] }, + finalmask: { + udp: [{ type: 'salamander', settings: { password: 'p' } }], + quicParams: { congestion: 'bbr', udpHop: { ports: '20000-30000', interval: '5-10' } }, + }, + }, + }; + const tree = (open: boolean) => ( + + + {}} + onConfirm={onConfirm} + /> + + + ); + const { rerender } = render(tree(false)); + rerender(tree(true)); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + const ok = document.querySelector('.ant-modal-footer .ant-btn-primary') as HTMLElement; + await act(async () => { + fireEvent.click(ok); + }); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + expect(onConfirm).toHaveBeenCalledTimes(1); + const payload = onConfirm.mock.calls[0][0] as { + streamSettings: { finalmask: { udp: unknown[]; quicParams?: Record } }; + }; + expect(payload.streamSettings.finalmask.udp).toEqual([ + { type: 'salamander', settings: { password: 'p' } }, + { + type: 'udphop', + settings: { mode: 'intervalRemote', remotePorts: '20000-30000', interval: '5-10' }, + }, + ]); + expect(payload.streamSettings.finalmask.quicParams?.udpHop).toBeUndefined(); + expect(payload.streamSettings.finalmask.quicParams?.congestion).toBe('bbr'); + }); +}); + +describe('OutboundFormModal finalmask variables', () => { + function xhttpOutbound(finalmask: Record) { + return { + protocol: 'vless', + tag: 'masked', + settings: { + vnext: [ + { + address: 'example.com', + port: 443, + users: [{ id: 'c9f0c2d0-0000-4000-8000-000000000000', encryption: 'none' }], + }, + ], + }, + streamSettings: { + network: 'xhttp', + security: 'none', + xhttpSettings: { path: '/', mode: 'auto' }, + finalmask, + }, + }; + } + + async function saveFinalmask( + finalmask: Record, + edit: () => void = () => {}, + ): Promise> { + const onConfirm = vi.fn(); + const queryClient = makeTestQueryClient(); + const tree = (open: boolean) => ( + + + {}} + onConfirm={onConfirm} + /> + + + ); + const { rerender } = render(tree(false)); + rerender(tree(true)); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + edit(); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + const ok = document.querySelector('.ant-modal-footer .ant-btn-primary') as HTMLElement; + await act(async () => { + fireEvent.click(ok); + }); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + expect(onConfirm).toHaveBeenCalledTimes(1); + return ( + onConfirm.mock.calls[0][0] as { streamSettings: { finalmask: Record } } + ).streamSettings.finalmask; + } + + function typeInto(label: string, value: string) { + const input = formItemByLabel(label)?.querySelector('input, textarea'); + if (!input) throw new Error(`${label} input not found`); + fireEvent.change(input, { target: { value } }); + } + + // xray-core 26.6 header-custom variables, error replies and standalone UDP headers, plus + // the sudoku UDP mask; the same fixture is built by Go's TestGoldenStreamFixturesBuildInXray. + it('keeps every header-custom variable, sudoku UDP mask and noise reset range on save', async () => { + const fixture = (await import('./golden/fixtures/finalmask/header-custom-variables.json')) + .default as Record; + + expect(await saveFinalmask(structuredClone(fixture))).toEqual(fixture); + }); + + // The core rejects an item carrying two kinds ("exactly one item kind must be set"). + it('drops the bytes of an item switched to reuse a variable', async () => { + const finalmask = await saveFinalmask( + { + udp: [ + { + type: 'header-custom', + settings: { client: [{ type: 'array', rand: 4, randRange: '0-255' }], server: [] }, + }, + ], + }, + () => { + chooseOptionByLabel('Kind', 'Reuse variable'); + typeInto('Reuse', 'nonce'); + }, + ); + + expect(finalmask.udp).toEqual([ + { type: 'header-custom', settings: { client: [{ reuse: 'nonce' }], server: [] } }, + ]); + }); + + // A string transform fails the whole core config, so half-typed JSON must never be saved. + it.each([ + ['{"op": "be16", "args": [{"u64": 7}]}', [{ transform: { op: 'be16', args: [{ u64: 7 }] } }]], + ['{"op": "be16"', [{}]], + ])('saves the typed transform %s only once it parses', async (typed, client) => { + const finalmask = await saveFinalmask( + { + udp: [ + { type: 'header-custom', settings: { client: [{ type: 'array', rand: 4 }], server: [] } }, + ], + }, + () => { + chooseOptionByLabel('Kind', 'Transform'); + typeInto('Transform', typed); + }, + ); + + expect(finalmask.udp).toEqual([{ type: 'header-custom', settings: { client, server: [] } }]); + }); + + it('saves a noise reset typed as a range', async () => { + const finalmask = await saveFinalmask( + { udp: [{ type: 'noise', settings: { reset: 0, noise: [] } }] }, + () => typeInto('Reset', '60-120'), + ); + + expect(finalmask.udp).toEqual([{ type: 'noise', settings: { reset: '60-120', noise: [] } }]); + }); }); diff --git a/frontend/src/test/outbound-link-parser.test.ts b/frontend/src/test/outbound-link-parser.test.ts index 6a69a6dc7..6b14302c9 100644 --- a/frontend/src/test/outbound-link-parser.test.ts +++ b/frontend/src/test/outbound-link-parser.test.ts @@ -225,14 +225,19 @@ describe('parseVlessLink — XHTTP advanced fields', () => { }); describe('parseVlessLink', () => { - // A panel older than xray-core 26.9.30 shares xdns in the string lists the core no - // longer parses, so an outbound imported verbatim would fail the whole config. - it('upgrades a legacy xdns fm= mask to the object shape', () => { - const fm = encodeURIComponent( - JSON.stringify({ - udp: [{ type: 'xdns', settings: { resolvers: ['t.example.com+udp://8.8.8.8:53'] } }], - }), - ); + // Older panels share xdns as string lists (pre-26.9.30, a load error) or as name/typed + // resolver objects (26.9.30) that xray-core 26.10.10 reads as no domain at all. + it.each([ + ['pre-26.9.30 string lists', { resolvers: ['t.example.com+udp://8.8.8.8:53'] }], + [ + '26.9.30 objects', + { + domains: [{ name: 't.example.com', types: [16], edns0: 1232 }], + resolvers: [{ type: 'udp', settings: { addr: '8.8.8.8:53' } }], + }, + ], + ])('upgrades a %s xdns fm= mask to names/addrs', (_shape, settings) => { + const fm = encodeURIComponent(JSON.stringify({ udp: [{ type: 'xdns', settings }] })); const out = parseVlessLink( `vless://11111111-2222-4333-8444-555555555555@srv:53?type=kcp&security=none&fm=${fm}#dns`, ); @@ -240,8 +245,8 @@ describe('parseVlessLink', () => { udp: Array<{ settings: unknown }>; }; expect(finalmask.udp[0].settings).toEqual({ - domains: [{ name: 't.example.com', types: [16], edns0: 1232 }], - resolvers: [{ type: 'udp', settings: { addr: '8.8.8.8:53' } }], + domains: [{ names: ['t.example.com'], types: [16], edns0: 1232 }], + resolvers: [{ addrs: ['udp://8.8.8.8:53'] }], }); }); @@ -483,6 +488,25 @@ describe('parseShadowsocksLink', () => { }); describe('parseHysteria2Link', () => { + // A panel older than the 26.9.9 core shares its hop range as quicParams.udpHop in fm=, + // which the core ignores; the import must hand it the udphop mask instead. + it('upgrades a legacy fm= quicParams.udpHop to the udphop mask', () => { + const fm = encodeURIComponent( + JSON.stringify({ quicParams: { udpHop: { ports: '20000-30000', interval: '5-10' } } }), + ); + const out = parseHysteria2Link( + `hysteria2://auth@hy.example.com:443?sni=hy.example.com&fm=${fm}#hy`, + ); + expect((out!.streamSettings as Record).finalmask).toEqual({ + udp: [ + { + type: 'udphop', + settings: { mode: 'intervalRemote', remotePorts: '20000-30000', interval: '5-10' }, + }, + ], + }); + }); + it('parses a hysteria2:// link with sni', () => { const link = 'hysteria2://auth-secret@srv.example:443?sni=example.com#imported-hy2'; const out = parseHysteria2Link(link); @@ -817,8 +841,14 @@ describe('parseVlessLink — extra / fm / x_padding_bytes (B20)', () => { const quicParams = finalmask.quicParams as Record; expect(quicParams.congestion).toBe('bbr'); expect(quicParams.maxIdleTimeout).toBe(30); - expect((quicParams.udpHop as Record).interval).toBe('5-10'); - expect((quicParams.udpHop as Record).ports).toBe('20000-50000'); + // xray-core 26.9.9 ignores quicParams.udpHop; the import moves it to the udphop mask. + expect(quicParams.udpHop).toBeUndefined(); + expect(finalmask.udp).toEqual([ + { + type: 'udphop', + settings: { mode: 'intervalRemote', remotePorts: '20000-50000', interval: '5-10' }, + }, + ]); }); it('falls back to x_padding_bytes when extra has no xPaddingBytes', () => { diff --git a/frontend/src/test/routing-script.test.tsx b/frontend/src/test/routing-script.test.tsx new file mode 100644 index 000000000..a4b98e183 --- /dev/null +++ b/frontend/src/test/routing-script.test.tsx @@ -0,0 +1,75 @@ +import { useEffect, useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, screen } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +import BasicsTab from '@/pages/xray/basics/BasicsTab'; +import RoutingTab from '@/pages/xray/routing/RoutingTab'; +import type { SetTemplate, XraySettingsValue } from '@/hooks/useXraySetting'; +import { renderWithProviders } from './test-utils'; + +const SCRIPT_BANNER = 'A routing script is set under Basics, so Xray ignores these rules.'; + +function renderRoutingRules(routing: Record) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + renderWithProviders( + + + , + ); + fireEvent.click(screen.getByRole('tab', { name: /Routing Rules/ })); +} + +// xray-core 26.10.10 routes only through routing.script once it is set, skipping +// every rule, the panel's api rule included. +describe('routing script', () => { + it('writes the script path from Basics and drops the key once cleared', () => { + const seen = vi.fn(); + const latest = () => seen.mock.lastCall?.[0] as { routing: Record }; + function Harness() { + const [templateSettings, setTemplateSettings] = useState({ + routing: { domainStrategy: 'AsIs', rules: [] }, + outbounds: [], + } as unknown as XraySettingsValue); + useEffect(() => seen(templateSettings), [templateSettings]); + const updateTemplate: SetTemplate = (next) => { + setTemplateSettings((current) => (typeof next === 'function' ? next(current) : next)); + }; + return ( + + ); + } + + renderWithProviders(); + const script = screen.getByRole('textbox', { name: 'Routing Script' }); + fireEvent.change(script, { target: { value: 'route.lua' } }); + expect(latest().routing.script).toBe('route.lua'); + + fireEvent.change(script, { target: { value: ' ' } }); + expect(latest().routing).not.toHaveProperty('script'); + }); + + it('warns above the rules while a script is set', () => { + renderRoutingRules({ rules: [], script: 'route.lua' }); + + expect(screen.getByText(SCRIPT_BANNER)).toBeTruthy(); + }); + + it('shows no warning without a script', () => { + renderRoutingRules({ rules: [] }); + + expect(screen.queryByText(SCRIPT_BANNER)).toBeNull(); + }); +}); diff --git a/frontend/src/test/use-xray-setting.test.tsx b/frontend/src/test/use-xray-setting.test.tsx index 5ec5edf2f..34dbe252c 100644 --- a/frontend/src/test/use-xray-setting.test.tsx +++ b/frontend/src/test/use-xray-setting.test.tsx @@ -49,6 +49,32 @@ describe('useXraySetting', () => { expect(result.current.xraySetting).toBe('{"outbounds":[]}'); }); + // xray-core 26.10.10 runs a Lua dns.script that addresses servers by id; stripped + // on load, the next template save would silently delete both. + it('keeps the dns script and server ids through the load', async () => { + const payload = xrayPayload({ + xraySetting: { + dns: { script: 'dns.lua', servers: [{ address: '1.1.1.1', port: 53, id: 'cf' }] }, + }, + }); + vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => { + if (url === '/panel/api/xray/') return new Msg(true, '', JSON.stringify(payload)); + return new Msg(true, ''); + }); + const queryClient = makeTestQueryClient(); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => useXraySetting(), { wrapper }); + + await waitFor(() => expect(result.current.fetched).toBe(true)); + const loaded = JSON.parse(result.current.xraySetting) as { + dns: { script?: string; servers: Array> }; + }; + expect(loaded.dns.script).toBe('dns.lua'); + expect(loaded.dns.servers[0].id).toBe('cf'); + }); + it('keeps the outbound test URL input empty when it is cleared', async () => { const payload = xrayPayload({ outboundTestUrl: 'https://www.google.com/generate_204' }); vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => { diff --git a/frontend/src/test/warp-masque-modal.test.tsx b/frontend/src/test/warp-masque-modal.test.tsx new file mode 100644 index 000000000..d440d4e7d --- /dev/null +++ b/frontend/src/test/warp-masque-modal.test.tsx @@ -0,0 +1,100 @@ +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { act, fireEvent, screen, waitFor } from '@testing-library/react'; + +import WarpModal from '@/pages/xray/overrides/WarpModal'; +import { HttpUtil, Msg } from '@/utils'; +import { renderWithProviders } from './test-utils'; + +const registration = { + privateKey: '-----BEGIN PRIVATE KEY-----\nAAA\n-----END PRIVATE KEY-----\n', + publicKey: '-----BEGIN PUBLIC KEY-----\nBBB\n-----END PUBLIC KEY-----\n', + address: ['172.16.0.2', '2606:4700:110:8a36::2'], + endpoint: '162.159.198.1', +}; + +function mockApi() { + vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => { + if (url === '/panel/api/xray/warp/regMasque') { + return new Msg(true, '', JSON.stringify(registration)); + } + if (url === '/panel/api/setting/all') return new Msg(true, '', {}); + return new Msg(true, '', ''); + }); +} + +async function clickAddMasque() { + const button = await screen.findByRole('button', { name: /Add WARP over MASQUE outbound/ }); + // The modal loads its WARP data on open; a loading button swallows the click. + await waitFor(() => expect(button.className).not.toContain('ant-btn-loading')); + await act(async () => { + fireEvent.click(button); + }); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// WARP over MASQUE dials Cloudflare's MASQUE endpoint with the enrolled key, its SNI +// and HTTP/3; an outbound missing any of them fails the handshake at runtime. +describe('WarpModal WARP over MASQUE', () => { + const expected = { + tag: 'warp-masque', + protocol: 'masque', + settings: { address: '162.159.198.1', port: 443 }, + streamSettings: { + network: 'masque', + security: 'tls', + tlsSettings: { serverName: 'consumer-masque.cloudflareclient.com', alpn: ['h3'] }, + masqueSettings: { + warp: { + privateKey: registration.privateKey, + publicKey: registration.publicKey, + address: registration.address, + }, + }, + }, + }; + + it('adds a MASQUE outbound built from a fresh enrollment', async () => { + mockApi(); + const onAddOutbound = vi.fn(); + renderWithProviders( + {}} + onAddOutbound={onAddOutbound} + onResetOutbound={() => {}} + onRemoveOutbound={() => {}} + />, + ); + + await clickAddMasque(); + + await waitFor(() => expect(onAddOutbound).toHaveBeenCalledWith(expected)); + }); + + it('replaces an existing warp-masque outbound instead of adding a second one', async () => { + mockApi(); + const onAddOutbound = vi.fn(); + const onResetOutbound = vi.fn(); + renderWithProviders( + {}} + onAddOutbound={onAddOutbound} + onResetOutbound={onResetOutbound} + onRemoveOutbound={() => {}} + />, + ); + + await clickAddMasque(); + + await waitFor(() => + expect(onResetOutbound).toHaveBeenCalledWith({ index: 1, outbound: expected }), + ); + expect(onAddOutbound).not.toHaveBeenCalled(); + }); +}); diff --git a/go.mod b/go.mod index 29283408e..70afbf3a9 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/mhsanaei/3x-ui/v3 -go 1.27.1 +go 1.27.2 require ( github.com/amnezia-vpn/amneziawg-go/v3 v3.1.20260828 @@ -8,7 +8,7 @@ require ( github.com/gin-contrib/gzip v1.2.8 github.com/gin-contrib/sessions v1.1.2 github.com/gin-gonic/gin v1.12.0 - github.com/go-ldap/ldap/v3 v3.4.14 + github.com/go-ldap/ldap/v3 v3.4.15 github.com/go-playground/validator/v10 v10.30.5 github.com/goccy/go-json v0.11.2 github.com/goccy/go-yaml v1.19.2 @@ -28,12 +28,12 @@ require ( github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/valyala/fasthttp v1.75.0 github.com/xlzd/gotp v0.1.0 - github.com/xtls/xray-core v1.260327.1-0.20260930074004-b26a91de4f32 + github.com/xtls/xray-core v1.260327.1-0.20261010092107-701af60772cd go.uber.org/atomic v1.12.0 - golang.org/x/crypto v0.57.0 - golang.org/x/net v0.59.0 - golang.org/x/sys v0.48.0 - golang.org/x/text v0.42.0 + golang.org/x/crypto v0.58.0 + golang.org/x/net v0.61.0 + golang.org/x/sys v0.49.0 + golang.org/x/text v0.43.0 google.golang.org/grpc v1.86.0-dev google.golang.org/protobuf v1.36.12 gopkg.in/natefinch/lumberjack.v2 v2.2.1 @@ -67,7 +67,7 @@ require ( github.com/huin/goupnp v1.3.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jackc/puddle/v2 v2.2.3 // indirect github.com/jackpal/go-nat-pmp v1.1.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect @@ -83,7 +83,7 @@ require ( github.com/miekg/dns v1.1.73 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/molecule-man/go-brrr v1.1.1 // indirect + github.com/molecule-man/go-brrr v1.2.0 // indirect github.com/pelletier/go-toml/v2 v2.4.3 // indirect github.com/pion/dtls/v3 v3.1.10 // indirect github.com/pion/logging v0.2.4 // indirect @@ -104,16 +104,19 @@ require ( github.com/vishvananda/netns v0.0.5 // indirect github.com/wlynxg/anet v0.0.5 // indirect github.com/xtls/reality v0.0.0-20260921001439-3c98159dee38 // indirect + github.com/yuin/gopher-lua v1.1.2 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect - go.mongodb.org/mongo-driver/v2 v2.9.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.9.2 // indirect go4.org/netipx v0.0.0-20260823151212-3075585bcbeb // indirect - golang.org/x/arch v0.31.0 // indirect - golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba // indirect - golang.org/x/sync v0.23.0 // indirect + golang.org/x/arch v0.32.0 // indirect + golang.org/x/crypto/x509roots/fallback v0.0.0-20261009200856-99e4382b128e // indirect + golang.org/x/exp v0.0.0-20261009195045-ca0d7ba23607 // indirect + golang.org/x/sync v0.24.0 // indirect golang.org/x/time v0.16.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect - golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 // indirect + golang.zx2c4.com/wireguard v0.0.0-20261006164505-2631ce99a06f // indirect golang.zx2c4.com/wireguard/windows v1.1.1 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20261005182115-fad411399dd8 // indirect + layeh.com/gopher-luar v1.0.11 // indirect lukechampine.com/blake3 v1.4.1 // indirect ) diff --git a/go.sum b/go.sum index 51ebfcce8..913f2751d 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,9 @@ github.com/bytedance/sonic v1.15.4 h1:FgtV/4aBHpla9AxuMpuuzVUpa/Cf3izufkxNmnEzdI github.com/bytedance/sonic v1.15.4/go.mod h1:8e51yTPdY8M6t+vvGL1c2Y1xL9i+frEeIAQAEl75NUc= github.com/bytedance/sonic/loader v0.5.2 h1:0QtP1gevc1OZ6/H8Lb9BRZiCXd1Ftjd3OKuj1T1lBIo= github.com/bytedance/sonic/loader v0.5.2/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/cloudflare/circl v1.6.5 h1:O64F26HEqNhznd/hrC5KZXVKYuKM2rx4deZDTc4ihQA= github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGnz5OstK6c= github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI= @@ -39,8 +42,8 @@ github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ= github.com/go-asn1-ber/asn1-ber v1.5.8/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= -github.com/go-ldap/ldap/v3 v3.4.14 h1:D6PYdEgsaVzsXyr6w/yDC06Ria4uUhWm+Rb+er8lfAs= -github.com/go-ldap/ldap/v3 v3.4.14/go.mod h1:S4eJUMUNjDkE0ZJtIZdybwyb03sGGLW6gxXT1Hs8VKA= +github.com/go-ldap/ldap/v3 v3.4.15 h1:eRB4f4Ecn1POL1mO86+uUDlecmyWsdiJwh60bAUS4UY= +github.com/go-ldap/ldap/v3 v3.4.15/go.mod h1:jeD0c98EdZQCKKKKe4kTWs5aCf6y+j8KuYsYLznZSQk= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= @@ -89,8 +92,8 @@ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7Ulw github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= -github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= -github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jackc/puddle/v2 v2.2.3 h1:f8HoREISb3l5eO9coPgK7U3hKLhWGokOFVcThl5jrao= +github.com/jackc/puddle/v2 v2.2.3/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackpal/go-nat-pmp v1.1.0 h1:UInMLPV1VQdP860ggNiz0YxGvJH/bWzxL099y+1EdCs= github.com/jackpal/go-nat-pmp v1.1.0/go.mod h1:m9o4DK1wHA4h2pPpErD5vwzWLf91tJcfNQ3QyUIbh5A= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= @@ -144,8 +147,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/molecule-man/go-brrr v1.1.1 h1:KYwusQhjtV3smALnhmafZf4OBrh7vP4p8O47DyUmmuY= -github.com/molecule-man/go-brrr v1.1.1/go.mod h1:7ybW6/7gA3oKY45jOfVNjSJDtrr6ea4tzbsTkjmQDC4= +github.com/molecule-man/go-brrr v1.2.0 h1:dOJU45BC3Gc2WXoxAW0rgCAgVuu2gruAOGncfieA0Jw= +github.com/molecule-man/go-brrr v1.2.0/go.mod h1:7ybW6/7gA3oKY45jOfVNjSJDtrr6ea4tzbsTkjmQDC4= github.com/mymmrac/telego v1.12.1 h1:yx1T5pPSNsU3BjLR7jnfY0D4dtL9caH58Y9e8uzjR88= github.com/mymmrac/telego v1.12.1/go.mod h1:K4z3Z3Qr6AA8yEjSry3JGScu506NlLl1O4Gqascmop4= github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ= @@ -223,14 +226,17 @@ github.com/xlzd/gotp v0.1.0 h1:37blvlKCh38s+fkem+fFh7sMnceltoIEBYTVXyoa5Po= github.com/xlzd/gotp v0.1.0/go.mod h1:ndLJ3JKzi3xLmUProq4LLxCuECL93dG9WASNLpHz8qg= github.com/xtls/reality v0.0.0-20260921001439-3c98159dee38 h1:Q5KwiMm+WRyw2kwPW4+fIQxNio1CyrWo/eAEaAHxl/Q= github.com/xtls/reality v0.0.0-20260921001439-3c98159dee38/go.mod h1:/YQ6FwmAtkDuo8K3ujKQH1Br5eGNFauBipdl1gdiebk= -github.com/xtls/xray-core v1.260327.1-0.20260930074004-b26a91de4f32 h1:Bxo6+07IvdWlqxugsn2/sQrFbR7hjrxRRDMjbOInRho= -github.com/xtls/xray-core v1.260327.1-0.20260930074004-b26a91de4f32/go.mod h1:FAjlDAzHXXyki7R1BxruCHFx19B3i8TvQGEy+wsqZWU= +github.com/xtls/xray-core v1.260327.1-0.20261010092107-701af60772cd h1:Am/9Pzw5PZr7+hMN1Vs5faeSXRoutnrwMAgtUDnh/J8= +github.com/xtls/xray-core v1.260327.1-0.20261010092107-701af60772cd/go.mod h1:YAACF9L/Pu1GznixHOBtrBqVcE95nhFjgU+gIEdWKlo= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yuin/gopher-lua v0.0.0-20190206043414-8bfc7677f583/go.mod h1:gqRgreBUhTSL0GeU64rtZ3Uq3wtjOa/TB2YfrtkCbVQ= +github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA= +github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= -go.mongodb.org/mongo-driver/v2 v2.9.1 h1:jewiFs2m1/VOQp8qhFshX6hWZ+EAXDhZHXExAUMcOgQ= -go.mongodb.org/mongo-driver/v2 v2.9.1/go.mod h1:SHKN0IWkKmEVGHLjXnni6s4wPKX4v86FTgOeJJFuXcA= +go.mongodb.org/mongo-driver/v2 v2.9.2 h1:OCAdjI37pC/SVxucz/9CM8zD4jCdegtsY5QcKzYFCgE= +go.mongodb.org/mongo-driver/v2 v2.9.2/go.mod h1:SHKN0IWkKmEVGHLjXnni6s4wPKX4v86FTgOeJJFuXcA= go.uber.org/atomic v1.12.0 h1:BvcXdFKuviU4fTL/f+SxdQ5qJX/Jix8pAkgdUcb3XOE= go.uber.org/atomic v1.12.0/go.mod h1:I6c4cg+6HCxRjfjSsYtApoFILnpc0CGUdGkXVqbYVNk= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= @@ -239,40 +245,43 @@ go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= go4.org/netipx v0.0.0-20260823151212-3075585bcbeb h1:XBM4hvfwGAttkkiTIFfeigdfcL1xIfdKXqFdgiHGtDs= go4.org/netipx v0.0.0-20260823151212-3075585bcbeb/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= -golang.org/x/arch v0.31.0 h1:22MlEb14/O/EPCYHFxsDdv5TuLD5dMjT5e2QeJw4ULk= -golang.org/x/arch v0.31.0/go.mod h1:KcJSod3cqT2dKcjBxqTyGfbumNikqU9p5tHJinPJnuY= -golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= -golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= -golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba h1:Ck8QetSgk912qxWLMCKxd0in+aiyBQyDSMae6e/xmpU= -golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba/go.mod h1:50RgIsmK7OwqzTTeqcSXQW8SswW0o8fRcDxmqGluJ8E= -golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= -golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/arch v0.32.0 h1:NjGf8/xGf5JgAJ4dw4NtME5R1L7xatagqAg95T3LgII= +golang.org/x/arch v0.32.0/go.mod h1:KcJSod3cqT2dKcjBxqTyGfbumNikqU9p5tHJinPJnuY= +golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= +golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= +golang.org/x/crypto/x509roots/fallback v0.0.0-20261009200856-99e4382b128e h1:jsB4A6J5vF93GeMqp72mLAOcFfGXgHtxpgeLk7OJB4M= +golang.org/x/crypto/x509roots/fallback v0.0.0-20261009200856-99e4382b128e/go.mod h1:HPze8vhfG6fO06AM+VSvxRm4E3+5Yk375mgrJ5M2z1E= +golang.org/x/exp v0.0.0-20261009195045-ca0d7ba23607 h1:o259es6R0mYtmCeID0xv+dkp5kQSf6qp8zs0j4MdUaI= +golang.org/x/exp v0.0.0-20261009195045-ca0d7ba23607/go.mod h1:SRjTzy5WcYqRRqquLUvHeeovSwLO0CZ3xcltZNO4Fck= +golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= +golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= -golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= +golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= -golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= -golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= +golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= -golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU= -golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0= +golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI= +golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= -golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 h1:cqHQ3AycTHvM2R7ikgyX57D+XvtcSnGylsLkOVhta/w= -golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= +golang.zx2c4.com/wireguard v0.0.0-20261006164505-2631ce99a06f h1:gJI6OVshWW0mvGyTVmW8mJoaw2OM69wVCeZkwpX+Jdc= +golang.zx2c4.com/wireguard v0.0.0-20261006164505-2631ce99a06f/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= golang.zx2c4.com/wireguard/windows v1.1.1 h1:8/H97U1v1PNDNcBsMZgU3KFuND9MQdTsU2NOwmCXArE= golang.zx2c4.com/wireguard/windows v1.1.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc h1:4bNTbnb44EqGVy9HaQxSY2jnafSUdgV3qHtedvNpDKg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261005182115-fad411399dd8 h1:0SXBqli5dnuCKMjI/X3FM226zEaEa0GUcSCJtN1vL2o= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261005182115-fad411399dd8/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= google.golang.org/grpc v1.86.0-dev h1:FLq7hcApuJeJ70J9+k0FvmXsNCwS8B2/MeAfYdy3GHY= google.golang.org/grpc v1.86.0-dev/go.mod h1:Ovl0ECo4xx5r4kn/6d4BPSNB7OIFuu6EAjOzjtVAKaM= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= @@ -294,6 +303,8 @@ gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 h1:Lk6hARj5UPY47dBep70OD/TIMwikJ5fGUGX0Rm3Xigk= gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q= +layeh.com/gopher-luar v1.0.11 h1:8zJudpKI6HWkoh9eyyNFaTM79PY6CAPcIr6X/KTiliw= +layeh.com/gopher-luar v1.0.11/go.mod h1:TPnIVCZ2RJBndm7ohXyaqfhzjlZ+OA2SZR/YwL8tECk= lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= pgregory.net/rapid v1.3.0 h1:vBvO0VSqti75J1jjYqpgPNBLKMd1+gxa9fYo7vk/Exc= diff --git a/internal/database/db.go b/internal/database/db.go index b2027ccb0..a14c8911a 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -1305,7 +1305,7 @@ func runSeeders(isUsersEmpty bool) error { } if empty && isUsersEmpty { - seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "UppercaseFreedomFinalRulesFix", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "OutboundRemovedKeysFix", "FreedomDomainStrategyFix", "DNSOutboundLegacyKeysFix", "DNSOutboundQTypeZeroFix", "WireguardDomainStrategyFix", "XdnsFinalmaskObjectsFix", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "ResetIpLimitNoFail2ban"} + seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "UppercaseFreedomFinalRulesFix", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "OutboundRemovedKeysFix", "FreedomDomainStrategyFix", "DNSOutboundLegacyKeysFix", "DNSOutboundQTypeZeroFix", "WireguardDomainStrategyFix", "XdnsFinalmaskNamesAddrsFix", "UDPHopClientMaskFix", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "ResetIpLimitNoFail2ban"} for _, name := range seeders { if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil { return err @@ -1446,8 +1446,14 @@ func runSeeders(isUsersEmpty bool) error { } } - if !slices.Contains(seedersHistory, "XdnsFinalmaskObjectsFix") { - if err := migrateXdnsFinalmaskObjects(); err != nil { + if !slices.Contains(seedersHistory, "XdnsFinalmaskNamesAddrsFix") { + if err := migrateXdnsFinalmaskShape(); err != nil { + return err + } + } + + if !slices.Contains(seedersHistory, "UDPHopClientMaskFix") { + if err := migrateUDPHopClientMasks(); err != nil { return err } } @@ -1951,16 +1957,16 @@ func strategyIsSet(value any) bool { return s != "" && !strings.EqualFold(s, "asis") } -// migrateXdnsFinalmaskObjects upgrades every stored xdns mask to the object shape -// xray-core 26.9.30 requires, wherever the panel keeps a finalmask. -func migrateXdnsFinalmaskObjects() error { +// migrateXdnsFinalmaskShape upgrades every stored xdns mask to the names/addrs shape +// xray-core 26.10.10 reads, wherever the panel keeps a finalmask. +func migrateXdnsFinalmaskShape() error { return db.Transaction(func(tx *gorm.DB) error { var inbounds []model.Inbound if err := tx.Select("id", "stream_settings").Find(&inbounds).Error; err != nil { return err } for _, inbound := range inbounds { - if updated, changed := upgradeLegacyXdnsJSON(inbound.StreamSettings, streamFinalmask, false); changed { + if updated, changed := upgradeFinalmasksJSON(inbound.StreamSettings, streamFinalmask, false, maskcompat.UpgradeLegacyXdns); changed { if err := tx.Model(&model.Inbound{}).Where("id = ?", inbound.Id). Update("stream_settings", updated).Error; err != nil { return err @@ -1972,7 +1978,7 @@ func migrateXdnsFinalmaskObjects() error { return err } for _, host := range hosts { - if updated, changed := upgradeLegacyXdnsJSON(host.FinalMask, wholeFinalmask, false); changed { + if updated, changed := upgradeFinalmasksJSON(host.FinalMask, wholeFinalmask, false, maskcompat.UpgradeLegacyXdns); changed { if err := tx.Model(&model.Host{}).Where("id = ?", host.Id). Update("final_mask", updated).Error; err != nil { return err @@ -1984,7 +1990,7 @@ func migrateXdnsFinalmaskObjects() error { return err } for _, sub := range subs { - if updated, changed := upgradeLegacyXdnsJSON(sub.LastFetchedOutbounds, outboundListFinalmasks, false); changed { + if updated, changed := upgradeFinalmasksJSON(sub.LastFetchedOutbounds, outboundListFinalmasks, false, maskcompat.UpgradeLegacyXdns); changed { if err := tx.Model(&model.OutboundSubscription{}).Where("id = ?", sub.Id). Update("last_fetched_outbounds", updated).Error; err != nil { return err @@ -2007,20 +2013,75 @@ func migrateXdnsFinalmaskObjects() error { if err != nil { return err } - if updated, changed := upgradeLegacyXdnsJSON(setting.Value, stored.locate, stored.indent); changed { + if updated, changed := upgradeFinalmasksJSON(setting.Value, stored.locate, stored.indent, maskcompat.UpgradeLegacyXdns); changed { if err := tx.Model(&model.Setting{}).Where("key = ?", stored.key). Update("value", updated).Error; err != nil { return err } } } - return tx.Create(&model.HistoryOfSeeders{SeederName: "XdnsFinalmaskObjectsFix"}).Error + return tx.Create(&model.HistoryOfSeeders{SeederName: "XdnsFinalmaskNamesAddrsFix"}).Error }) } -// upgradeLegacyXdnsJSON rewrites the finalmasks locate finds in one stored JSON document, -// leaving the document byte-for-byte alone when nothing in it is legacy. -func upgradeLegacyXdnsJSON(raw string, locate func(any) []any, indent bool) (string, bool) { +// migrateUDPHopClientMasks moves the quicParams.udpHop xray-core 26.9.9 ignores into a +// udphop mask in every client-side finalmask; inbounds keep it, udphop refuses to run there. +func migrateUDPHopClientMasks() error { + return db.Transaction(func(tx *gorm.DB) error { + var hosts []model.Host + if err := tx.Select("id", "final_mask").Find(&hosts).Error; err != nil { + return err + } + for _, host := range hosts { + if updated, changed := upgradeFinalmasksJSON(host.FinalMask, wholeFinalmask, false, maskcompat.UpgradeLegacyUDPHop); changed { + if err := tx.Model(&model.Host{}).Where("id = ?", host.Id). + Update("final_mask", updated).Error; err != nil { + return err + } + } + } + var subs []model.OutboundSubscription + if err := tx.Select("id", "last_fetched_outbounds").Find(&subs).Error; err != nil { + return err + } + for _, sub := range subs { + if updated, changed := upgradeFinalmasksJSON(sub.LastFetchedOutbounds, outboundListFinalmasks, false, maskcompat.UpgradeLegacyUDPHop); changed { + if err := tx.Model(&model.OutboundSubscription{}).Where("id = ?", sub.Id). + Update("last_fetched_outbounds", updated).Error; err != nil { + return err + } + } + } + for _, stored := range []struct { + key string + locate func(any) []any + indent bool + }{ + {"xrayTemplateConfig", templateOutboundFinalmasks, true}, + {"subJsonFinalMask", wholeFinalmask, false}, + } { + var setting model.Setting + err := tx.Where("key = ?", stored.key).First(&setting).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + continue + } + if err != nil { + return err + } + if updated, changed := upgradeFinalmasksJSON(setting.Value, stored.locate, stored.indent, maskcompat.UpgradeLegacyUDPHop); changed { + if err := tx.Model(&model.Setting{}).Where("key = ?", stored.key). + Update("value", updated).Error; err != nil { + return err + } + } + } + return tx.Create(&model.HistoryOfSeeders{SeederName: "UDPHopClientMaskFix"}).Error + }) +} + +// upgradeFinalmasksJSON runs upgrade on the finalmasks locate finds in one stored JSON +// document, leaving the document byte-for-byte alone when nothing in it changed. +func upgradeFinalmasksJSON(raw string, locate func(any) []any, indent bool, upgrade func(any) bool) (string, bool) { if strings.TrimSpace(raw) == "" { return raw, false } @@ -2030,7 +2091,7 @@ func upgradeLegacyXdnsJSON(raw string, locate func(any) []any, indent bool) (str } changed := false for _, mask := range locate(doc) { - if maskcompat.UpgradeLegacyXdns(mask) { + if upgrade(mask) { changed = true } } @@ -2067,6 +2128,11 @@ func outboundListFinalmasks(doc any) []any { return finalmasks } +func templateOutboundFinalmasks(doc any) []any { + cfg, _ := doc.(map[string]any) + return outboundListFinalmasks(cfg["outbounds"]) +} + func templateFinalmasks(doc any) []any { cfg, _ := doc.(map[string]any) return append(outboundListFinalmasks(cfg["inbounds"]), outboundListFinalmasks(cfg["outbounds"])...) diff --git a/internal/database/model/model.go b/internal/database/model/model.go index cabbbb2de..18c61158f 100644 --- a/internal/database/model/model.go +++ b/internal/database/model/model.go @@ -34,6 +34,7 @@ const ( MTProto Protocol = "mtproto" AmneziaWG Protocol = "amneziawg" TUIC Protocol = "tuic" + MASQUE Protocol = "masque" ) // User represents a user account in the 3x-ui panel. @@ -64,7 +65,7 @@ type Inbound struct { // Xray configuration fields Listen string `json:"listen" form:"listen"` Port int `json:"port" form:"port" validate:"gte=0,lte=65535" example:"443"` - Protocol Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel tun mtproto amneziawg tuic" example:"vless"` + Protocol Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel tun mtproto amneziawg tuic masque" example:"vless"` Settings string `json:"settings" form:"settings"` StreamSettings string `json:"streamSettings" form:"streamSettings"` Tag string `json:"tag" form:"tag" gorm:"unique" example:"in-443-tcp"` @@ -371,6 +372,10 @@ func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig { if healed, ok := HealHysteriaVersion(settings); ok { settings = healed } + case MASQUE: + if converted, ok := MasqueClientsToCore(settings); ok { + settings = converted + } } streamSettings := i.StreamSettings if stripped, ok := StripInboundXhttpClientFields(streamSettings); ok { @@ -426,6 +431,46 @@ func StripVmessClientSecurity(settings string) (string, bool) { return string(out), true } +// MasqueClientsToCore renames each client's panel "password" to the "pass" key the +// MASQUE server reads; xray-core refuses the whole inbound when one user's pass is empty. +func MasqueClientsToCore(settings string) (string, bool) { + if settings == "" { + return settings, false + } + var parsed map[string]any + if err := json.Unmarshal([]byte(settings), &parsed); err != nil { + return settings, false + } + clients, ok := parsed["clients"].([]any) + if !ok { + return settings, false + } + changed := false + for i := range clients { + cm, ok := clients[i].(map[string]any) + if !ok { + continue + } + password, has := cm["password"] + if !has { + continue + } + if _, set := cm["pass"]; !set { + cm["pass"] = password + } + delete(cm, "password") + changed = true + } + if !changed { + return settings, false + } + out, err := json.MarshalIndent(parsed, "", " ") + if err != nil { + return settings, false + } + return string(out), true +} + // WireguardPeerFromClient builds the xray wireguard inbound peer object for one // WireGuard client. It is the single definition of the peer shape, shared by the // full-config path (XrayService.GetXrayConfig) and the live AddInbound path diff --git a/internal/database/udphop_client_mask_migration_test.go b/internal/database/udphop_client_mask_migration_test.go new file mode 100644 index 000000000..2e38026ce --- /dev/null +++ b/internal/database/udphop_client_mask_migration_test.go @@ -0,0 +1,117 @@ +package database + +import ( + "encoding/json" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +const legacyHopFinalmask = `{"quicParams":{"udpHop":{"ports":"20000-30000","interval":"5-10"}}}` + +const upgradedHopFinalmask = `{"udp":[{"settings":{"interval":"5-10","mode":"intervalRemote","remotePorts":"20000-30000"},"type":"udphop"}]}` + +func assertHopUpgraded(t *testing.T, where string, finalmask any) { + t.Helper() + got, err := json.Marshal(finalmask) + if err != nil { + t.Fatalf("%s: marshal finalmask: %v", where, err) + } + if string(got) != upgradedHopFinalmask { + t.Fatalf("%s: finalmask\n got: %s\nwant: %s", where, got, upgradedHopFinalmask) + } +} + +// xray-core 26.9.9 ignores quicParams.udpHop, so every client-side finalmask the panel +// stored before then hops nowhere; inbounds keep it, since udphop refuses to run there. +func TestUDPHopSeederMovesClientHopsToTheMask(t *testing.T) { + initMigrateDB(t) + inboundStream := `{"network":"hysteria","security":"tls","finalmask":` + legacyHopFinalmask + `}` + ib := seedInboundWithStream(t, "hy-in", 8443, inboundStream) + host := &model.Host{InboundId: ib.Id, Remark: "h", Address: "cdn.example.com", Port: 8443, FinalMask: legacyHopFinalmask} + if err := GetDB().Create(host).Error; err != nil { + t.Fatalf("create host: %v", err) + } + seedTemplate(t, `{"inbounds":[{"tag":"tpl-in","streamSettings":{"finalmask":`+legacyHopFinalmask+`}}], + "outbounds":[{"protocol":"hysteria","tag":"hy-out","settings":{},"streamSettings":{"network":"hysteria","finalmask":`+legacyHopFinalmask+`}}]}`) + if err := GetDB().Create(&model.Setting{Key: "subJsonFinalMask", Value: legacyHopFinalmask}).Error; err != nil { + t.Fatalf("seed subJsonFinalMask: %v", err) + } + sub := &model.OutboundSubscription{ + Remark: "donor", Url: "https://donor.example.com/sub", + LastFetchedOutbounds: `[{"protocol":"hysteria","tag":"sub-1","settings":{},"streamSettings":{"network":"hysteria","finalmask":` + legacyHopFinalmask + `}}]`, + } + if err := GetDB().Create(sub).Error; err != nil { + t.Fatalf("create outbound subscription: %v", err) + } + if err := GetDB().Where("seeder_name = ?", "UDPHopClientMaskFix").Delete(&model.HistoryOfSeeders{}).Error; err != nil { + t.Fatalf("clear seeder history: %v", err) + } + + if err := runSeeders(false); err != nil { + t.Fatalf("runSeeders: %v", err) + } + + var storedHost model.Host + if err := GetDB().First(&storedHost, host.Id).Error; err != nil { + t.Fatalf("reload host: %v", err) + } + var hostMask any + _ = json.Unmarshal([]byte(storedHost.FinalMask), &hostMask) + assertHopUpgraded(t, "host finalMask", hostMask) + + var template struct { + Inbounds []struct { + StreamSettings struct { + Finalmask json.RawMessage `json:"finalmask"` + } `json:"streamSettings"` + } `json:"inbounds"` + Outbounds []struct { + StreamSettings struct { + Finalmask any `json:"finalmask"` + } `json:"streamSettings"` + } `json:"outbounds"` + } + if err := json.Unmarshal([]byte(storedTemplate(t)), &template); err != nil || len(template.Outbounds) != 1 || len(template.Inbounds) != 1 { + t.Fatalf("stored template unreadable (%v)", err) + } + assertHopUpgraded(t, "template outbound", template.Outbounds[0].StreamSettings.Finalmask) + var inboundMask any + _ = json.Unmarshal(template.Inbounds[0].StreamSettings.Finalmask, &inboundMask) + var legacy any + _ = json.Unmarshal([]byte(legacyHopFinalmask), &legacy) + want, _ := json.Marshal(legacy) + if got, _ := json.Marshal(inboundMask); string(got) != string(want) { + t.Fatalf("template inbound finalmask = %s, want it untouched", got) + } + + var subMask model.Setting + if err := GetDB().Where("key = ?", "subJsonFinalMask").First(&subMask).Error; err != nil { + t.Fatalf("reload subJsonFinalMask: %v", err) + } + var subFinalmask any + _ = json.Unmarshal([]byte(subMask.Value), &subFinalmask) + assertHopUpgraded(t, "subJsonFinalMask", subFinalmask) + + var storedSub model.OutboundSubscription + if err := GetDB().First(&storedSub, sub.Id).Error; err != nil { + t.Fatalf("reload outbound subscription: %v", err) + } + var cached []struct { + StreamSettings struct { + Finalmask any `json:"finalmask"` + } `json:"streamSettings"` + } + if err := json.Unmarshal([]byte(storedSub.LastFetchedOutbounds), &cached); err != nil || len(cached) != 1 { + t.Fatalf("cached subscription outbounds unreadable (%v)", err) + } + assertHopUpgraded(t, "cached subscription outbound", cached[0].StreamSettings.Finalmask) + + var stored model.Inbound + if err := GetDB().First(&stored, ib.Id).Error; err != nil { + t.Fatalf("reload inbound: %v", err) + } + if stored.StreamSettings != inboundStream { + t.Fatalf("inbound stream = %s, want it untouched", stored.StreamSettings) + } +} diff --git a/internal/database/xdns_finalmask_migration_test.go b/internal/database/xdns_finalmask_migration_test.go index 0f3d08715..469037ec6 100644 --- a/internal/database/xdns_finalmask_migration_test.go +++ b/internal/database/xdns_finalmask_migration_test.go @@ -7,10 +7,12 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/database/model" ) -const legacyXdnsFinalmask = `{"udp":[{"type":"xdns","settings":{"domains":["t.example.com"]}}]}` +// legacyXdnsFinalmask is the 26.9.30 object shape the previous seeder left in panel DBs; +// xray-core 26.10.10 reads neither its "name" nor its typed resolver. +const legacyXdnsFinalmask = `{"udp":[{"type":"xdns","settings":{"domains":[{"name":"t.example.com","types":[16],"edns0":1232}],"resolvers":[{"type":"udp","settings":{"addr":"8.8.8.8:53"}}]}}]}` -// assertXdnsUpgraded fails unless the finalmask's xdns domains are objects, the only -// shape xray-core 26.9.30 parses. +// assertXdnsUpgraded fails unless the finalmask's xdns mask uses the names/addrs lists, +// the only shape xray-core 26.10.10 reads. func assertXdnsUpgraded(t *testing.T, where string, finalmask any) { t.Helper() fm, _ := finalmask.(map[string]any) @@ -20,13 +22,13 @@ func assertXdnsUpgraded(t *testing.T, where string, finalmask any) { } mask, _ := udp[0].(map[string]any) settings, _ := mask["settings"].(map[string]any) - domains, _ := settings["domains"].([]any) - if len(domains) != 1 { - t.Fatalf("%s: domains = %v, want one entry", where, settings["domains"]) + got, err := json.Marshal(settings) + if err != nil { + t.Fatalf("%s: marshal xdns settings: %v", where, err) } - domain, ok := domains[0].(map[string]any) - if !ok || domain["name"] != "t.example.com" { - t.Fatalf("%s: domain = %#v, want an object named t.example.com", where, domains[0]) + const want = `{"domains":[{"edns0":1232,"names":["t.example.com"],"types":[16]}],"resolvers":[{"addrs":["udp://8.8.8.8:53"]}]}` + if string(got) != want { + t.Fatalf("%s: xdns settings\n got: %s\nwant: %s", where, got, want) } } @@ -49,7 +51,7 @@ func TestXdnsFinalmaskSeederUpgradesEveryStoredMask(t *testing.T) { if err := GetDB().Create(sub).Error; err != nil { t.Fatalf("create outbound subscription: %v", err) } - if err := GetDB().Where("seeder_name = ?", "XdnsFinalmaskObjectsFix"). + if err := GetDB().Where("seeder_name = ?", "XdnsFinalmaskNamesAddrsFix"). Delete(&model.HistoryOfSeeders{}).Error; err != nil { t.Fatalf("clear seeder history: %v", err) } diff --git a/internal/sub/json_service.go b/internal/sub/json_service.go index fd615b2f8..edcc85e33 100644 --- a/internal/sub/json_service.go +++ b/internal/sub/json_service.go @@ -16,6 +16,7 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/logger" "github.com/mhsanaei/3x-ui/v3/internal/util/json_util" + "github.com/mhsanaei/3x-ui/v3/internal/util/maskcompat" "github.com/mhsanaei/3x-ui/v3/internal/util/random" wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard" ) @@ -640,6 +641,8 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c if finalmask, ok := newStream["finalmask"].(map[string]any); ok { newStream["finalmask"] = withLegacyFragmentRanges(finalmask) } + // An inbound keeps its hop range as quicParams.udpHop; clients hop only with the mask. + maskcompat.UpgradeLegacyUDPHop(newStream["finalmask"]) streamSettings, _ := json.MarshalIndent(newStream, "", " ") hostMux := hostMuxOverride(extPrxy) @@ -663,6 +666,8 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c newOutbounds = append(newOutbounds, s.genServer(subReq, inbound, streamSettings, client, jsonMux(mux, hostMux))) case "hysteria": newOutbounds = append(newOutbounds, s.genHy(inbound, newStream, client, jsonMux(mux, hostMux))) + case "masque": + newOutbounds = append(newOutbounds, s.genMasque(inbound, newStream, client)) case "wireguard": wgOutbound := s.genWireguard(inbound, client) if wgOutbound == nil { @@ -999,6 +1004,7 @@ func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any, if finalmask, ok := hyStream["finalmask"].(map[string]any); ok { newStream["finalmask"] = mergeFinalMask(newStream["finalmask"], finalmask) } + maskcompat.UpgradeLegacyUDPHop(newStream["finalmask"]) newStream["network"] = "hysteria" newStream["security"] = "tls" @@ -1009,6 +1015,32 @@ func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any, return result } +// genMasque builds the client side of a MASQUE inbound: the server reads only the +// path, and authenticates the CONNECT-IP request by the client's email and password. +func (s *SubJsonService) genMasque(inbound *model.Inbound, newStream map[string]any, client model.Client) json_util.RawMessage { + outbound := Outbound{ + Protocol: string(inbound.Protocol), + Tag: "proxy", + Settings: map[string]any{ + "address": inbound.Listen, + "port": inbound.Port, + }, + } + masqueSettings := map[string]any{"user": client.Email, "pass": client.Password} + if server, ok := newStream["masqueSettings"].(map[string]any); ok { + if path, ok := server["path"].(string); ok && path != "" { + masqueSettings["path"] = path + } + } + newStream["masqueSettings"] = masqueSettings + newStream["network"] = "masque" + newStream["security"] = "tls" + outbound.StreamSettings, _ = json.MarshalIndent(newStream, "", " ") + + result, _ := json.MarshalIndent(outbound, "", " ") + return result +} + // genWireguard builds an Xray wireguard outbound for a native WireGuard inbound, // mirroring genWireguardLink: the peer public key is derived from the inbound // secretKey, the client owns the private key / tunnel address / pre-shared key, diff --git a/internal/sub/masque_test.go b/internal/sub/masque_test.go new file mode 100644 index 000000000..58572b629 --- /dev/null +++ b/internal/sub/masque_test.go @@ -0,0 +1,116 @@ +package sub + +import ( + "encoding/json" + "reflect" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +// MASQUE has no share-link format, so the JSON subscription is the only way a client +// gets one: a masque outbound authenticating with the client's email and password. +func TestSubJsonServiceMasqueClientConfig(t *testing.T) { + inbound := &model.Inbound{ + Listen: "203.0.113.9", Port: 8443, Protocol: model.MASQUE, Remark: "masque", + Settings: `{"clients":[{"email":"ivy@example.test","password":"pw-ivy"}],"address":["10.14.0.1/24"]}`, + StreamSettings: `{"network":"masque","security":"tls", + "masqueSettings":{"path":"/.well-known/masque/ip/*/*/"}, + "tlsSettings":{"serverName":"masque.example.test","alpn":["h3"], + "certificates":[{"certificateFile":"/etc/ssl/m.crt","keyFile":"/etc/ssl/m.key"}], + "settings":{"fingerprint":"chrome"}}}`, + } + client := model.Client{Email: "ivy@example.test", Password: "pw-ivy"} + + configs := NewSubJsonService("", "", "", "", nil).getConfig(&SubService{address: "sub.example.com"}, inbound, client, "sub.example.com") + if len(configs) != 1 { + t.Fatalf("got %d configs, want one", len(configs)) + } + var config struct { + Outbounds []struct { + Protocol string `json:"protocol"` + Settings map[string]any `json:"settings"` + StreamSettings map[string]any `json:"streamSettings"` + } `json:"outbounds"` + } + if err := json.Unmarshal(configs[0], &config); err != nil { + t.Fatalf("decode config: %v", err) + } + proxy := config.Outbounds[0] + if proxy.Protocol != "masque" { + t.Fatalf("first outbound protocol = %q, want masque", proxy.Protocol) + } + if proxy.Settings["address"] != "203.0.113.9" || proxy.Settings["port"] != float64(8443) { + t.Fatalf("masque settings = %v, want the inbound address and port", proxy.Settings) + } + wantMasque := map[string]any{"path": "/.well-known/masque/ip/*/*/", "user": "ivy@example.test", "pass": "pw-ivy"} + if got := proxy.StreamSettings["masqueSettings"]; !reflect.DeepEqual(got, wantMasque) { + t.Fatalf("masqueSettings = %v, want %v", got, wantMasque) + } + tls, _ := proxy.StreamSettings["tlsSettings"].(map[string]any) + if proxy.StreamSettings["network"] != "masque" || proxy.StreamSettings["security"] != "tls" || + tls["serverName"] != "masque.example.test" || tls["certificates"] != nil { + t.Fatalf("stream = %v, want masque over client-side TLS to masque.example.test", proxy.StreamSettings) + } +} + +// The subscription SQL allowlist decides which inbounds a subId can see at all; left +// off it, a MASQUE client's JSON subscription would come back empty. +func TestGetInboundsBySubIdIncludesMasque(t *testing.T) { + initSubDB(t) + db := database.GetDB() + + in := &model.Inbound{Port: 8443, Protocol: model.MASQUE, Enable: true, Tag: "masque-sub", Settings: `{"clients":[],"address":["10.14.0.1/24"]}`} + if err := db.Create(in).Error; err != nil { + t.Fatalf("create inbound: %v", err) + } + rec := &model.ClientRecord{Email: "u@masque", SubID: "submasque", Enable: true} + if err := db.Create(rec).Error; err != nil { + t.Fatalf("create client: %v", err) + } + if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: in.Id}).Error; err != nil { + t.Fatalf("create link: %v", err) + } + + inbounds, err := (&SubService{}).getInboundsBySubId("submasque") + if err != nil { + t.Fatalf("getInboundsBySubId: %v", err) + } + if len(inbounds) != 1 || inbounds[0].Id != in.Id { + t.Fatalf("masque inbound not returned for subId: %+v", inbounds) + } +} + +// An inbound keeps its hop range in quicParams.udpHop (udphop refuses to run on a server), +// but the client config must carry the udphop mask: the core ignores the old key since 26.9.9. +func TestSubJsonServiceTurnsInboundHopIntoClientUDPHopMask(t *testing.T) { + inbound := &model.Inbound{ + Listen: "203.0.113.10", Port: 443, Protocol: model.Hysteria, Remark: "hy", + Settings: `{"version":2,"clients":[{"email":"ivy@example.test","auth":"a"}]}`, + StreamSettings: `{"network":"hysteria","security":"tls", + "hysteriaSettings":{"version":2}, + "tlsSettings":{"serverName":"hy.example.test","alpn":["h3"]}, + "finalmask":{"quicParams":{"udpHop":{"ports":"20000-30000","interval":"5-10"}}}}`, + } + client := model.Client{Email: "ivy@example.test", Auth: "a"} + + configs := NewSubJsonService("", "", "", "", nil).getConfig(&SubService{address: "sub.example.com"}, inbound, client, "sub.example.com") + if len(configs) != 1 { + t.Fatalf("got %d configs, want one", len(configs)) + } + var config struct { + Outbounds []struct { + Protocol string `json:"protocol"` + StreamSettings map[string]any `json:"streamSettings"` + } `json:"outbounds"` + } + if err := json.Unmarshal(configs[0], &config); err != nil { + t.Fatalf("decode config: %v", err) + } + got, _ := json.Marshal(config.Outbounds[0].StreamSettings["finalmask"]) + const want = `{"udp":[{"settings":{"interval":"5-10","mode":"intervalRemote","remotePorts":"20000-30000"},"type":"udphop"}]}` + if string(got) != want { + t.Fatalf("client finalmask\n got: %s\nwant: %s", got, want) + } +} diff --git a/internal/sub/service.go b/internal/sub/service.go index ec5a51818..fa1493cdb 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -685,7 +685,7 @@ func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id JOIN clients ON clients.id = client_inbounds.client_id WHERE - inbounds.protocol in ('vmess','vless','trojan','shadowsocks','hysteria','wireguard','amneziawg','mtproto','tuic') + inbounds.protocol in ('vmess','vless','trojan','shadowsocks','hysteria','wireguard','amneziawg','mtproto','tuic','masque') AND clients.sub_id = ? AND inbounds.enable = ? )`, subId, true).Order("sub_sort_index ASC").Order("id ASC").Find(&inbounds).Error if err != nil { @@ -818,9 +818,13 @@ func mergeStreamFromMaster(childStream, masterStream string) string { // GetLink dispatches to the protocol-specific generator for one (inbound, client) // pair. Returns "" when the inbound's protocol doesn't produce a subscription URL -// (socks, http, mixed, wireguard, dokodemo, tunnel). The returned string may -// contain multiple `\n`-separated URLs when the inbound has externalProxy set. +// (socks, http, mixed, wireguard, dokodemo, tunnel) or its transport has no link +// format (xdrive). The returned string may contain multiple `\n`-separated URLs +// when the inbound has externalProxy set. func (s *SubService) GetLink(inbound *model.Inbound, email string) string { + if network, _ := unmarshalStreamSettings(inbound.StreamSettings)["network"].(string); network == "xdrive" { + return "" + } switch inbound.Protocol { case "vmess": return s.genVmessLink(inbound, email) @@ -2696,6 +2700,7 @@ var validFinalMaskUDPTypes = map[string]struct{}{ "header-custom": {}, "realm": {}, "udphop": {}, + "sudoku": {}, } var validFinalMaskTCPTypes = map[string]struct{}{ diff --git a/internal/sub/service_test.go b/internal/sub/service_test.go index 4036e9d91..070a7e9b8 100644 --- a/internal/sub/service_test.go +++ b/internal/sub/service_test.go @@ -1181,6 +1181,20 @@ func TestMarshalFinalMask_KeepsUdpHopMask(t *testing.T) { } } +// xray-core 26.6 accepts sudoku as a UDP mask too; the link must not drop it from fm=. +func TestMarshalFinalMask_KeepsSudokuUdpMask(t *testing.T) { + fm := map[string]any{ + "udp": []any{map[string]any{"type": "sudoku", "settings": map[string]any{"password": "s3cr3t"}}}, + } + out, ok := marshalFinalMask(fm) + if !ok { + t.Fatal("expected ok=true for a sudoku udp mask") + } + if want := `{"udp":[{"settings":{"password":"s3cr3t"},"type":"sudoku"}]}`; out != want { + t.Fatalf("marshalFinalMask = %s, want %s", out, want) + } +} + func TestHasFinalMaskContent(t *testing.T) { if hasFinalMaskContent(nil) { t.Fatal("nil should not count as content") diff --git a/internal/sub/xdrive_test.go b/internal/sub/xdrive_test.go new file mode 100644 index 000000000..ca7de7cfe --- /dev/null +++ b/internal/sub/xdrive_test.go @@ -0,0 +1,15 @@ +package sub + +import "testing" + +// XDRIVE has no share-link format: a link would either drop the storage secrets both +// ends need or print them into every QR code, so only the JSON subscription carries it. +func TestGetLink_SkipsXdriveStreams(t *testing.T) { + stream := `{"network":"xdrive","security":"none", + "xdriveSettings":{"service":"Google Drive","remoteFolder":"xray-tunnel","secrets":["id","secret","token"]}}` + inbound := flowTestInbound(stream, "none") + + if link := (&SubService{}).GetLink(inbound, "user"); link != "" { + t.Fatalf("GetLink = %q, want no link for an XDRIVE stream", link) + } +} diff --git a/internal/util/link/outbound.go b/internal/util/link/outbound.go index 5af07f658..6829e680d 100644 --- a/internal/util/link/outbound.go +++ b/internal/util/link/outbound.go @@ -769,6 +769,7 @@ func applyFinalMask(stream map[string]any, p url.Values) { if json.Unmarshal([]byte(fm), &parsed) == nil { sanitizeFinalMaskQuicParams(parsed) maskcompat.UpgradeLegacyXdns(parsed) + maskcompat.UpgradeLegacyUDPHop(parsed) stream["finalmask"] = parsed } } diff --git a/internal/util/link/outbound_test.go b/internal/util/link/outbound_test.go index 08de2b3ad..bee390a7b 100644 --- a/internal/util/link/outbound_test.go +++ b/internal/util/link/outbound_test.go @@ -87,10 +87,10 @@ func TestParseVlessLink_FinalMaskQuicParamsSanitized(t *testing.T) { } } -// A panel older than xray-core 26.9.30 shares its xdns mask in the string lists the -// core no longer parses; imported verbatim, the outbound would fail the whole config. +// A panel on xray-core 26.9.30 shares xdns objects whose "name" and typed resolvers +// 26.10.10 ignores; imported verbatim, the outbound would dial with no domain. func TestParseLink_UpgradesLegacyXdnsFinalMask(t *testing.T) { - fm := url.QueryEscape(`{"udp":[{"type":"xdns","settings":{"resolvers":["t.example.com+udp://8.8.8.8:53"]}}]}`) + fm := url.QueryEscape(`{"udp":[{"type":"xdns","settings":{"domains":[{"name":"t.example.com","types":[16],"edns0":1232}],"resolvers":[{"type":"udp","settings":{"addr":"8.8.8.8:53"}}]}}]}`) res, err := ParseLink("vless://uuid@1.2.3.4:53?type=kcp&security=none&fm=" + fm + "#dns") if err != nil { t.Fatalf("parse vless with fm: %v", err) @@ -100,7 +100,7 @@ func TestParseLink_UpgradesLegacyXdnsFinalMask(t *testing.T) { if err != nil { t.Fatalf("marshal finalmask: %v", err) } - want := `{"udp":[{"settings":{"domains":[{"edns0":1232,"name":"t.example.com","types":[16]}],"resolvers":[{"settings":{"addr":"8.8.8.8:53"},"type":"udp"}]},"type":"xdns"}]}` + want := `{"udp":[{"settings":{"domains":[{"edns0":1232,"names":["t.example.com"],"types":[16]}],"resolvers":[{"addrs":["udp://8.8.8.8:53"]}]},"type":"xdns"}]}` if string(got) != want { t.Fatalf("imported finalmask\n got: %s\nwant: %s", got, want) } @@ -304,9 +304,9 @@ func TestParseHysteria2_Mport(t *testing.T) { wantHop: true, }, { - name: "legacy fm quicParams.udpHop no longer suppresses mport", + name: "legacy fm quicParams.udpHop is upgraded and wins over mport like the mask", query: "mport=1-2&fm=" + url.QueryEscape(`{"quicParams":{"udpHop":{"ports":"30000-40000","interval":"7-9"}}}`), - wantPorts: "1-2", + wantPorts: "30000-40000", wantHop: true, }, } @@ -577,3 +577,22 @@ func TestParseShadowsocksObfsLocalPlugin(t *testing.T) { }) } } + +// A panel older than the 26.9.9 core shares its hop range as quicParams.udpHop inside fm=, +// which the core ignores; imported verbatim, the outbound would never hop. +func TestParseLink_UpgradesLegacyUDPHopFinalMask(t *testing.T) { + fm := url.QueryEscape(`{"quicParams":{"udpHop":{"ports":"20000-30000","interval":"5-10"}}}`) + res, err := ParseLink("hysteria2://auth@hy.example.com:443?sni=hy.example.com&fm=" + fm + "#hy") + if err != nil { + t.Fatalf("parse hysteria2 with fm: %v", err) + } + stream, _ := res.Outbound["streamSettings"].(map[string]any) + got, err := json.Marshal(stream["finalmask"]) + if err != nil { + t.Fatalf("marshal finalmask: %v", err) + } + want := `{"udp":[{"settings":{"interval":"5-10","mode":"intervalRemote","remotePorts":"20000-30000"},"type":"udphop"}]}` + if string(got) != want { + t.Fatalf("imported finalmask\n got: %s\nwant: %s", got, want) + } +} diff --git a/internal/util/maskcompat/udphop.go b/internal/util/maskcompat/udphop.go new file mode 100644 index 000000000..f326f2621 --- /dev/null +++ b/internal/util/maskcompat/udphop.go @@ -0,0 +1,54 @@ +package maskcompat + +import "strings" + +// UpgradeLegacyUDPHop moves the quicParams.udpHop range xray-core 26.9.9 stopped reading +// into the client-only "udphop" UDP mask, whose intervalRemote mode is what the key did. +func UpgradeLegacyUDPHop(finalmask any) bool { + fm, _ := finalmask.(map[string]any) + quicParams, _ := fm["quicParams"].(map[string]any) + legacy, found := quicParams["udpHop"] + if !found { + return false + } + delete(quicParams, "udpHop") + if len(quicParams) == 0 { + delete(fm, "quicParams") + } + + hop, _ := legacy.(map[string]any) + ports := hop["ports"] + if isEmptyHopValue(ports) || hasUDPHopMask(fm) { + return true + } + settings := map[string]any{"mode": "intervalRemote", "remotePorts": ports} + if interval := hop["interval"]; !isEmptyHopValue(interval) { + settings["interval"] = interval + } + masks, _ := fm["udp"].([]any) + fm["udp"] = append(masks, map[string]any{"type": "udphop", "settings": settings}) + return true +} + +func hasUDPHopMask(fm map[string]any) bool { + masks, _ := fm["udp"].([]any) + for _, entry := range masks { + mask, _ := entry.(map[string]any) + if maskType, _ := mask["type"].(string); strings.EqualFold(maskType, "udphop") { + return true + } + } + return false +} + +func isEmptyHopValue(value any) bool { + switch v := value.(type) { + case nil: + return true + case string: + return strings.TrimSpace(v) == "" + case float64: + return v == 0 + } + return false +} diff --git a/internal/util/maskcompat/udphop_test.go b/internal/util/maskcompat/udphop_test.go new file mode 100644 index 000000000..e8e7bab1a --- /dev/null +++ b/internal/util/maskcompat/udphop_test.go @@ -0,0 +1,106 @@ +package maskcompat + +import ( + "encoding/json" + "testing" + + "github.com/xtls/xray-core/infra/conf" + "github.com/xtls/xray-core/transport/internet/finalmask/udphop" +) + +// coreUDPHop builds a finalmask's UDP masks through conf.Mask, the loader the core +// calls at startup, and returns the udphop config the client would hop with, if any. +func coreUDPHop(t *testing.T, finalmask map[string]any) *udphop.Config { + t.Helper() + raw, err := json.Marshal(finalmask) + if err != nil { + t.Fatalf("marshal finalmask: %v", err) + } + var fm conf.FinalMask + if err := json.Unmarshal(raw, &fm); err != nil { + t.Fatalf("decode finalmask: %v", err) + } + for _, mask := range fm.Udp { + built, err := mask.Build(false) + if err != nil { + t.Fatalf("the core refuses UDP mask %q: %v", mask.Type, err) + } + if hop, ok := built.(*udphop.Config); ok { + return hop + } + } + return nil +} + +func decodeFinalmask(t *testing.T, s string) map[string]any { + t.Helper() + var fm map[string]any + if err := json.Unmarshal([]byte(s), &fm); err != nil { + t.Fatalf("decode finalmask: %v", err) + } + return fm +} + +func TestUpgradeLegacyUDPHop(t *testing.T) { + fm := decodeFinalmask(t, `{"udp":[{"type":"salamander","settings":{"password":"p"}}], + "quicParams":{"congestion":"bbr","udpHop":{"ports":"20000-20003","interval":"5-10"}}}`) + if hop := coreUDPHop(t, fm); hop != nil { + t.Fatal("the core hops on quicParams.udpHop again; the upgrade is no longer needed") + } + + if !UpgradeLegacyUDPHop(fm) { + t.Fatal("UpgradeLegacyUDPHop reported no change for a legacy hop") + } + got, _ := json.Marshal(fm) + const want = `{"quicParams":{"congestion":"bbr"},"udp":[{"settings":{"password":"p"},"type":"salamander"},{"settings":{"interval":"5-10","mode":"intervalRemote","remotePorts":"20000-20003"},"type":"udphop"}]}` + if string(got) != want { + t.Fatalf("upgraded finalmask\n got: %s\nwant: %s", got, want) + } + hop := coreUDPHop(t, fm) + if hop == nil || !hop.Remote || hop.Local || hop.IntervalMin != 5 || hop.IntervalMax != 10 || + len(hop.RemotePorts) != 4 || hop.RemotePorts[0] != 20000 || hop.RemotePorts[3] != 20003 { + t.Fatalf("the core hops with %+v, want remote ports 20000-20003 every 5-10s", hop) + } +} + +func TestUpgradeLegacyUDPHopEdgeCases(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "an explicit udphop mask wins over the dead key", + input: `{"udp":[{"type":"udphop","settings":{"mode":"perConnRemote","remotePorts":"443"}}],"quicParams":{"udpHop":{"ports":"20000-30000"}}}`, + want: `{"udp":[{"settings":{"mode":"perConnRemote","remotePorts":"443"},"type":"udphop"}]}`, + }, + { + name: "a hop without ports only loses the dead key", + input: `{"quicParams":{"udpHop":{"interval":"5-10"}}}`, + want: `{}`, + }, + { + name: "no interval keeps the core's 30s default", + input: `{"quicParams":{"udpHop":{"ports":"443,8443"}}}`, + want: `{"udp":[{"settings":{"mode":"intervalRemote","remotePorts":"443,8443"},"type":"udphop"}]}`, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + fm := decodeFinalmask(t, tc.input) + if !UpgradeLegacyUDPHop(fm) { + t.Fatal("UpgradeLegacyUDPHop reported no change") + } + got, _ := json.Marshal(fm) + if string(got) != tc.want { + t.Fatalf("upgraded finalmask\n got: %s\nwant: %s", got, tc.want) + } + coreUDPHop(t, fm) + }) + } + + current := decodeFinalmask(t, `{"udp":[{"type":"udphop","settings":{"mode":"intervalRemote","remotePorts":"443"}}],"quicParams":{"congestion":"bbr"}}`) + if UpgradeLegacyUDPHop(current) { + t.Fatal("UpgradeLegacyUDPHop rewrote a finalmask without the legacy key") + } +} diff --git a/internal/util/maskcompat/xdns.go b/internal/util/maskcompat/xdns.go index 6a2091e51..82b4c9a0b 100644 --- a/internal/util/maskcompat/xdns.go +++ b/internal/util/maskcompat/xdns.go @@ -1,6 +1,9 @@ package maskcompat -import "strings" +import ( + "slices" + "strings" +) // legacyXdnsEDNS0 is the EDNS0 payload the pre-26.9.30 xdns always negotiated; // the object shape makes it opt-in and caps every answer at 512 bytes without it. @@ -8,8 +11,8 @@ const legacyXdnsEDNS0 = 1232 var legacyXdnsRecordTypes = map[string]int{"": 16, "txt": 16, "a": 1, "aaaa": 28} -// UpgradeLegacyXdns rewrites xdns masks from the string lists xray-core 26.9.30 -// (#6718) no longer parses into its object lists; one legacy mask fails the whole config. +// UpgradeLegacyXdns rewrites xdns masks into the names/addrs lists xray-core 26.10.10 +// (#7090) parses: it ignores 26.9.30's name/type+settings keys and fails the string lists. func UpgradeLegacyXdns(finalmask any) bool { fm, _ := finalmask.(map[string]any) masks, _ := fm["udp"].([]any) @@ -31,48 +34,56 @@ func UpgradeLegacyXdns(finalmask any) bool { func upgradeLegacyXdnsSettings(settings map[string]any) bool { rawDomains, _ := settings["domains"].([]any) rawResolvers, _ := settings["resolvers"].([]any) - if !hasLegacyXdnsEntry(rawDomains) && !hasLegacyXdnsEntry(rawResolvers) { + if !slices.ContainsFunc(rawDomains, isLegacyXdnsDomain) && !slices.ContainsFunc(rawResolvers, isLegacyXdnsResolver) { return false } domains := make([]any, 0, len(rawDomains)) listed := map[string]bool{} addDomain := func(domain map[string]any) { - key, _ := domain["name"].(string) - key = strings.ToLower(key) - if key != "" && listed[key] { - return + names, _ := domain["names"].([]any) + for _, name := range names { + if s, ok := name.(string); ok { + listed[strings.ToLower(s)] = true + } } - listed[key] = true domains = append(domains, domain) } + addLegacyDomain := func(domain map[string]any) { + if name, _ := domain["names"].([]any)[0].(string); !listed[strings.ToLower(name)] { + addDomain(domain) + } + } for _, entry := range rawDomains { switch value := entry.(type) { case map[string]any: - addDomain(value) + addDomain(upgradeXdnsDomainObject(value)) case string: if domain, ok := legacyXdnsDomain(value); ok { - addDomain(domain) + addLegacyDomain(domain) } } } resolvers := make([]any, 0, len(rawResolvers)) for _, entry := range rawResolvers { - spec, ok := entry.(string) - if !ok { + switch value := entry.(type) { + case string: + head, addr, found := strings.Cut(value, "+udp://") + addr = strings.TrimSpace(addr) + domain, valid := legacyXdnsDomain(head) + if !found || addr == "" || !valid { + continue + } + addLegacyDomain(domain) + resolvers = append(resolvers, map[string]any{"addrs": []any{"udp://" + addr}}) + case map[string]any: + if !isLegacyXdnsResolver(value) { + resolvers = append(resolvers, value) + } else if addr, ok := legacyXdnsResolverAddr(value); ok { + resolvers = append(resolvers, map[string]any{"addrs": []any{addr}}) + } + default: resolvers = append(resolvers, entry) - continue } - head, addr, found := strings.Cut(spec, "+udp://") - addr = strings.TrimSpace(addr) - domain, valid := legacyXdnsDomain(head) - if !found || addr == "" || !valid { - continue - } - addDomain(domain) - resolvers = append(resolvers, map[string]any{ - "type": "udp", - "settings": map[string]any{"addr": addr}, - }) } settings["domains"] = domains if len(resolvers) > 0 { @@ -83,6 +94,40 @@ func upgradeLegacyXdnsSettings(settings map[string]any) bool { return true } +// upgradeXdnsDomainObject folds a 26.9.30 domain's single "name" into the "names" list. +func upgradeXdnsDomainObject(domain map[string]any) map[string]any { + raw, legacy := domain["name"] + if !legacy { + return domain + } + delete(domain, "name") + name, _ := raw.(string) + name = strings.TrimSpace(name) + names, _ := domain["names"].([]any) + if name != "" && !slices.Contains(names, any(name)) { + names = append([]any{name}, names...) + } + if names == nil { + names = []any{} + } + domain["names"] = names + return domain +} + +// legacyXdnsResolverAddr turns a 26.9.30 {type, settings:{addr}} resolver into the URL +// the addrs list takes; that loader refused every type but udp and tcp. +func legacyXdnsResolverAddr(resolver map[string]any) (string, bool) { + kind, _ := resolver["type"].(string) + kind = strings.ToLower(strings.TrimSpace(kind)) + settings, _ := resolver["settings"].(map[string]any) + addr, _ := settings["addr"].(string) + addr = strings.TrimSpace(addr) + if (kind != "udp" && kind != "tcp") || addr == "" { + return "", false + } + return kind + "://" + addr, true +} + // legacyXdnsDomain parses the "name[:txt|a|aaaa]" spec both legacy lists used. func legacyXdnsDomain(spec string) (map[string]any, bool) { name, method := strings.TrimSpace(spec), "" @@ -94,14 +139,27 @@ func legacyXdnsDomain(spec string) (map[string]any, bool) { if name == "" || !known { return nil, false } - return map[string]any{"name": name, "types": []any{recordType}, "edns0": legacyXdnsEDNS0}, true + return map[string]any{"names": []any{name}, "types": []any{recordType}, "edns0": legacyXdnsEDNS0}, true } -func hasLegacyXdnsEntry(values []any) bool { - for _, value := range values { - if _, ok := value.(string); ok { - return true - } +func isLegacyXdnsDomain(value any) bool { + switch domain := value.(type) { + case string: + return true + case map[string]any: + _, legacy := domain["name"] + return legacy + } + return false +} + +func isLegacyXdnsResolver(value any) bool { + switch resolver := value.(type) { + case string: + return true + case map[string]any: + _, current := resolver["addrs"] + return !current } return false } diff --git a/internal/util/maskcompat/xdns_test.go b/internal/util/maskcompat/xdns_test.go index 04b365209..2f91c392c 100644 --- a/internal/util/maskcompat/xdns_test.go +++ b/internal/util/maskcompat/xdns_test.go @@ -2,14 +2,16 @@ package maskcompat import ( "encoding/json" + "slices" "testing" "github.com/xtls/xray-core/infra/conf" + "github.com/xtls/xray-core/transport/internet/finalmask/xdns" ) -// buildXdnsSettings runs an xdns mask's settings through conf.XDNS, the loader -// the core calls at startup, so the test's verdict is the core's verdict. -func buildXdnsSettings(t *testing.T, settings any) error { +// coreXdnsView runs an xdns mask's settings through conf.XDNS, the loader the core +// calls at startup, and returns the domain names and resolvers the core would use. +func coreXdnsView(t *testing.T, settings any) (domains, resolvers []string, err error) { t.Helper() raw, err := json.Marshal(settings) if err != nil { @@ -17,42 +19,75 @@ func buildXdnsSettings(t *testing.T, settings any) error { } var mask conf.XDNS if err := json.Unmarshal(raw, &mask); err != nil { - return err + return nil, nil, err } - _, err = mask.Build() - return err + built, err := mask.Build() + if err != nil { + return nil, nil, err + } + config := built.(*xdns.Config) + for _, domain := range config.Domains { + domains = append(domains, domain.Name) + } + for _, resolver := range config.Resolvers { + resolvers = append(resolvers, resolver.Type+"://"+resolver.Addr) + } + return domains, resolvers, nil } func TestUpgradeLegacyXdns(t *testing.T) { tests := []struct { - name string - mask string - want string + name string + mask string + want string + wantDomains []string + wantResolvers []string }{ { - name: "bare server domain becomes TXT with the legacy EDNS0 size", - mask: `{"type":"xdns","settings":{"domains":["t.example.com"]}}`, - want: `{"domains":[{"edns0":1232,"name":"t.example.com","types":[16]}]}`, + name: "bare server domain becomes TXT with the legacy EDNS0 size", + mask: `{"type":"xdns","settings":{"domains":["t.example.com"]}}`, + want: `{"domains":[{"edns0":1232,"names":["t.example.com"],"types":[16]}]}`, + wantDomains: []string{"t.example.com"}, }, { - name: "method suffixes map to their record types", - mask: `{"type":"xdns","settings":{"domains":["a.example.com:a","q.example.com:AAAA","t.example.com:txt"]}}`, - want: `{"domains":[{"edns0":1232,"name":"a.example.com","types":[1]},{"edns0":1232,"name":"q.example.com","types":[28]},{"edns0":1232,"name":"t.example.com","types":[16]}]}`, + name: "method suffixes map to their record types", + mask: `{"type":"xdns","settings":{"domains":["a.example.com:a","q.example.com:AAAA","t.example.com:txt"]}}`, + want: `{"domains":[{"edns0":1232,"names":["a.example.com"],"types":[1]},{"edns0":1232,"names":["q.example.com"],"types":[28]},{"edns0":1232,"names":["t.example.com"],"types":[16]}]}`, + wantDomains: []string{"a.example.com", "q.example.com", "t.example.com"}, }, { - name: "client resolver splits into its domain and a udp resolver", - mask: `{"type":"XDNS","settings":{"resolvers":["t.example.com:a+udp://8.8.8.8:53"]}}`, - want: `{"domains":[{"edns0":1232,"name":"t.example.com","types":[1]}],"resolvers":[{"settings":{"addr":"8.8.8.8:53"},"type":"udp"}]}`, + name: "client resolver splits into its domain and a udp resolver", + mask: `{"type":"XDNS","settings":{"resolvers":["t.example.com:a+udp://8.8.8.8:53"]}}`, + want: `{"domains":[{"edns0":1232,"names":["t.example.com"],"types":[1]}],"resolvers":[{"addrs":["udp://8.8.8.8:53"]}]}`, + wantDomains: []string{"t.example.com"}, + wantResolvers: []string{"udp://8.8.8.8:53"}, }, { - name: "a resolver for an already listed domain adds no duplicate", - mask: `{"type":"xdns","settings":{"domains":["t.example.com"],"resolvers":["T.example.com+udp://1.1.1.1:53"]}}`, - want: `{"domains":[{"edns0":1232,"name":"t.example.com","types":[16]}],"resolvers":[{"settings":{"addr":"1.1.1.1:53"},"type":"udp"}]}`, + name: "a resolver for an already listed domain adds no duplicate", + mask: `{"type":"xdns","settings":{"domains":["t.example.com"],"resolvers":["T.example.com+udp://1.1.1.1:53"]}}`, + want: `{"domains":[{"edns0":1232,"names":["t.example.com"],"types":[16]}],"resolvers":[{"addrs":["udp://1.1.1.1:53"]}]}`, + wantDomains: []string{"t.example.com"}, + wantResolvers: []string{"udp://1.1.1.1:53"}, }, { - name: "entries the old core refused are dropped", - mask: `{"type":"xdns","settings":{"domains":["t.example.com","m.example.com:mx"],"resolvers":["1.1.1.1:53"]}}`, - want: `{"domains":[{"edns0":1232,"name":"t.example.com","types":[16]}]}`, + name: "string entries the old core refused are dropped", + mask: `{"type":"xdns","settings":{"domains":["t.example.com","m.example.com:mx"],"resolvers":["1.1.1.1:53"]}}`, + want: `{"domains":[{"edns0":1232,"names":["t.example.com"],"types":[16]}]}`, + wantDomains: []string{"t.example.com"}, + }, + { + name: "26.9.30 domain and resolver objects move to names and addrs", + mask: `{"type":"xdns","settings":{"domains":[{"name":"t.example.com","types":[16,28],"edns0":1232,"lenLimit":200}],"resolvers":[{"type":"tcp","settings":{"addr":"8.8.8.8:53"}},{"type":"udp","settings":{"addr":"1.1.1.1:5353"}}],"extraPoll":1}}`, + want: `{"domains":[{"edns0":1232,"lenLimit":200,"names":["t.example.com"],"types":[16,28]}],"extraPoll":1,"resolvers":[{"addrs":["tcp://8.8.8.8:53"]},{"addrs":["udp://1.1.1.1:5353"]}]}`, + wantDomains: []string{"t.example.com"}, + wantResolvers: []string{"tcp://8.8.8.8:53", "udp://1.1.1.1:5353"}, + }, + { + name: "26.9.30 resolvers the old loader refused are dropped", + mask: `{"type":"xdns","settings":{"domains":[{"name":"t.example.com"}],"resolvers":[{"type":"doh","settings":{"addr":"dns.example.com"}},{"type":"udp","settings":{}},{"type":"udp","settings":{"addr":"9.9.9.9:53"}}]}}`, + want: `{"domains":[{"names":["t.example.com"]}],"resolvers":[{"addrs":["udp://9.9.9.9:53"]}]}`, + wantDomains: []string{"t.example.com"}, + wantResolvers: []string{"udp://9.9.9.9:53"}, }, } for _, tc := range tests { @@ -61,8 +96,9 @@ func TestUpgradeLegacyXdns(t *testing.T) { if err := json.Unmarshal([]byte(tc.mask), &mask); err != nil { t.Fatalf("unmarshal mask: %v", err) } - if err := buildXdnsSettings(t, mask["settings"]); err == nil { - t.Fatal("the core accepted the legacy string shape; the upgrade is no longer needed") + domains, resolvers, err := coreXdnsView(t, mask["settings"]) + if err == nil && slices.Equal(domains, tc.wantDomains) && slices.Equal(resolvers, tc.wantResolvers) { + t.Fatal("the core already serves the legacy shape as intended; the upgrade is no longer needed") } finalmask := map[string]any{"udp": []any{mask}} if !UpgradeLegacyXdns(finalmask) { @@ -75,21 +111,25 @@ func TestUpgradeLegacyXdns(t *testing.T) { if string(got) != tc.want { t.Fatalf("upgraded settings\n got: %s\nwant: %s", got, tc.want) } - if err := buildXdnsSettings(t, mask["settings"]); err != nil { + domains, resolvers, err = coreXdnsView(t, mask["settings"]) + if err != nil { t.Fatalf("the core refuses the upgraded settings: %v", err) } + if !slices.Equal(domains, tc.wantDomains) || !slices.Equal(resolvers, tc.wantResolvers) { + t.Fatalf("the core serves domains %v resolvers %v, want %v %v", domains, resolvers, tc.wantDomains, tc.wantResolvers) + } }) } } func TestUpgradeLegacyXdnsLeavesCurrentShapeAlone(t *testing.T) { - const current = `{"udp":[{"type":"xdns","settings":{"domains":[{"name":"t.example.com","types":[16,28],"edns0":1232}],"resolvers":[{"type":"tcp","settings":{"addr":"8.8.8.8:53"}}],"extraPoll":2}},{"type":"salamander","settings":{"password":"x"}}]}` + const current = `{"udp":[{"type":"xdns","settings":{"domains":[{"names":["t.example.com","u.example.com"],"types":[16,28],"edns0":1232}],"resolvers":[{"addrs":["tcp://8.8.8.8:53","1.1.1.1"]}],"extraPoll":2}},{"type":"salamander","settings":{"password":"x"}}]}` var finalmask map[string]any if err := json.Unmarshal([]byte(current), &finalmask); err != nil { t.Fatalf("unmarshal finalmask: %v", err) } if UpgradeLegacyXdns(finalmask) { - t.Fatal("UpgradeLegacyXdns rewrote a mask that is already in the object shape") + t.Fatal("UpgradeLegacyXdns rewrote a mask that is already in the names/addrs shape") } var want map[string]any _ = json.Unmarshal([]byte(current), &want) diff --git a/internal/web/controller/xray_setting.go b/internal/web/controller/xray_setting.go index ca8b9ac72..16ea5fb36 100644 --- a/internal/web/controller/xray_setting.go +++ b/internal/web/controller/xray_setting.go @@ -195,6 +195,8 @@ func (a *XraySettingController) warp(c *gin.Context) { skey := c.PostForm("privateKey") pkey := c.PostForm("publicKey") resp, err = a.WarpService.RegWarp(skey, pkey) + case "regMasque": + resp, err = a.WarpService.RegWarpMasque() case "changeIp": resp, err = a.WarpService.ChangeWarpIP() if err == nil { diff --git a/internal/web/job/check_client_ip_job.go b/internal/web/job/check_client_ip_job.go index 5e802550e..1e1a3720d 100644 --- a/internal/web/job/check_client_ip_job.go +++ b/internal/web/job/check_client_ip_job.go @@ -694,7 +694,7 @@ func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, c // wireguard stays out: keepAlive marshals as a number, AddUser wants a string. protocol := string(inbound.Protocol) switch protocol { - case "vmess", "vless", "trojan", "shadowsocks", "hysteria": + case "vmess", "vless", "trojan", "shadowsocks", "hysteria", "masque": // supported protocols, continue default: logger.Warningf("[LIMIT_IP] Temporary disconnect is not supported for protocol %s on inbound %s", protocol, inbound.Tag) diff --git a/internal/web/middleware/validate_protocol_test.go b/internal/web/middleware/validate_protocol_test.go new file mode 100644 index 000000000..be98a5301 --- /dev/null +++ b/internal/web/middleware/validate_protocol_test.go @@ -0,0 +1,18 @@ +package middleware + +import ( + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +// The inbound add/update handlers validate the body with this validator, so a protocol +// missing from Inbound.Protocol's oneof makes every save of that protocol fail. +func TestValidateAcceptsMasqueInboundProtocol(t *testing.T) { + if err := validate.StructPartial(model.Inbound{Protocol: model.MASQUE}, "Protocol"); err != nil { + t.Fatalf("validate rejected a masque inbound: %v", err) + } + if err := validate.StructPartial(model.Inbound{Protocol: "dokodemo"}, "Protocol"); err == nil { + t.Fatal("validate accepted an unknown protocol; the oneof no longer guards it") + } +} diff --git a/internal/web/service/client_crud.go b/internal/web/service/client_crud.go index e967c0e01..e8fe14629 100644 --- a/internal/web/service/client_crud.go +++ b/internal/web/service/client_crud.go @@ -411,7 +411,7 @@ func (s *ClientService) fillProtocolDefaults(c *model.Client, ib *model.Inbound) if c.ID == "" { c.ID = uuid.NewString() } - case model.Trojan: + case model.Trojan, model.MASQUE: if c.Password == "" { c.Password = strings.ReplaceAll(uuid.NewString(), "-", "") } diff --git a/internal/web/service/client_inbound_apply.go b/internal/web/service/client_inbound_apply.go index b4386ca0c..3003759db 100644 --- a/internal/web/service/client_inbound_apply.go +++ b/internal/web/service/client_inbound_apply.go @@ -443,7 +443,7 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model return false, common.NewError("client email is required") } switch oldInbound.Protocol { - case "trojan": + case "trojan", "masque": if client.Password == "" { return false, common.NewError("empty client ID") } @@ -688,7 +688,7 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo newClientId := "" switch oldInbound.Protocol { - case "trojan": + case "trojan", "masque": newClientId = clients[0].Password case "shadowsocks": newClientId = clients[0].Email diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index 0e61d8914..cce52ff4a 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -656,6 +656,7 @@ func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) { model.Hysteria: true, model.WireGuard: true, model.Tunnel: true, + model.MASQUE: true, } if !protocolsWithStream[inbound.Protocol] { @@ -666,8 +667,8 @@ func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) { inbound.StreamSettings = canonicalizeLegacyXdnsMasks(inbound.StreamSettings) } -// canonicalizeLegacyXdnsMasks stores an xdns mask posted in the pre-26.9.30 string -// lists in the object shape the core parses, as GetXrayConfig would heal it anyway. +// canonicalizeLegacyXdnsMasks stores an xdns mask posted in a pre-26.10.10 shape in +// the names/addrs lists the core reads, as GetXrayConfig would heal it anyway. func canonicalizeLegacyXdnsMasks(streamSettings string) string { if streamSettings == "" { return streamSettings @@ -1241,7 +1242,7 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo // Secure client ID for _, client := range clients { switch inbound.Protocol { - case "trojan": + case "trojan", "masque": if client.Password == "" { return inbound, false, common.NewError("empty client ID") } @@ -1767,6 +1768,10 @@ func (s *InboundService) validateUpdatedInboundClients(inbound *model.Inbound) e if client.Auth == "" { return common.NewError("empty client ID") } + case model.MASQUE: + if client.Password == "" { + return common.NewError("empty client ID") + } case model.TUIC: if client.ID == "" { return common.NewError("empty client ID") diff --git a/internal/web/service/inbound_clients.go b/internal/web/service/inbound_clients.go index 5df50a142..0391affdb 100644 --- a/internal/web/service/inbound_clients.go +++ b/internal/web/service/inbound_clients.go @@ -230,7 +230,7 @@ func (s *InboundService) buildTargetClientFromSource(source model.Client, target inboundCanEnableTlsFlow(string(targetProtocol), targetInbound.StreamSettings, targetInbound.Settings) { target.Flow = flow } - case model.Trojan, model.Shadowsocks: + case model.Trojan, model.Shadowsocks, model.MASQUE: target.Password = s.generateRandomCredential(targetProtocol) case model.Hysteria: target.Auth = s.generateRandomCredential(targetProtocol) diff --git a/internal/web/service/inbound_finalmask_xdns_test.go b/internal/web/service/inbound_finalmask_xdns_test.go index 256c72c7a..7519f3017 100644 --- a/internal/web/service/inbound_finalmask_xdns_test.go +++ b/internal/web/service/inbound_finalmask_xdns_test.go @@ -8,9 +8,13 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/database/model" ) -const legacyXdnsStream = `{"network":"kcp","security":"none","kcpSettings":{"mtu":900},"finalmask":{"udp":[{"type":"xdns","settings":{"domains":["t.example.com"]}}]}}` +// legacyXdnsStream carries the 26.9.30 xdns objects, whose "name" and typed resolvers +// xray-core 26.10.10 ignores: the mask would load with no domain at all. +const legacyXdnsStream = `{"network":"kcp","security":"none","kcpSettings":{"mtu":900},"finalmask":{"udp":[{"type":"xdns","settings":{"domains":[{"name":"t.example.com","types":[16],"edns0":1232}],"resolvers":[{"type":"udp","settings":{"addr":"8.8.8.8:53"}}]}}]}}` -func firstXdnsDomain(t *testing.T, stream map[string]any) any { +const upgradedXdnsSettings = `{"domains":[{"edns0":1232,"names":["t.example.com"],"types":[16]}],"resolvers":[{"addrs":["udp://8.8.8.8:53"]}]}` + +func assertXdnsSettingsUpgraded(t *testing.T, stream map[string]any) { t.Helper() finalmask, _ := stream["finalmask"].(map[string]any) udp, _ := finalmask["udp"].([]any) @@ -18,17 +22,18 @@ func firstXdnsDomain(t *testing.T, stream map[string]any) any { t.Fatalf("finalmask.udp = %v, want one mask", finalmask["udp"]) } mask, _ := udp[0].(map[string]any) - settings, _ := mask["settings"].(map[string]any) - domains, _ := settings["domains"].([]any) - if len(domains) != 1 { - t.Fatalf("xdns domains = %v, want one", settings["domains"]) + got, err := json.Marshal(mask["settings"]) + if err != nil { + t.Fatalf("marshal xdns settings: %v", err) + } + if string(got) != upgradedXdnsSettings { + t.Fatalf("xdns settings\n got: %s\nwant: %s", got, upgradedXdnsSettings) } - return domains[0] } -// An API client can still post the pre-26.9.30 string lists; stored as sent they would -// reach the sub links and the form in a shape the core no longer parses. -func TestAddInbound_StoresXdnsMaskInObjectShape(t *testing.T) { +// An API client or an older panel's export can still post the 26.9.30 shape; stored as +// sent it would reach the sub links and the form in a shape the core silently empties. +func TestAddInbound_StoresXdnsMaskInNamesShape(t *testing.T) { setupConflictDB(t) in := &model.Inbound{ Tag: "in-45300-kcp", Enable: true, Listen: "0.0.0.0", Port: 45300, Protocol: model.VLESS, @@ -46,28 +51,16 @@ func TestAddInbound_StoresXdnsMaskInObjectShape(t *testing.T) { if err := json.Unmarshal([]byte(stored.StreamSettings), &stream); err != nil { t.Fatalf("stored stream is not JSON: %v", err) } - domain, ok := firstXdnsDomain(t, stream).(map[string]any) - if !ok || domain["name"] != "t.example.com" { - t.Fatalf("stored xdns domain = %#v, want an object named t.example.com", firstXdnsDomain(t, stream)) - } + assertXdnsSettingsUpgraded(t, stream) } // A row that never went through the save path (restored backup, node sync, direct DB -// edit) must still reach the core in a shape it builds, or it keeps every inbound down. +// edit) must still reach the core with its xdns domains, or the mask serves nothing. func TestGetXrayConfig_UpgradesLegacyXdnsMask(t *testing.T) { setupConflictDB(t) seedInboundConflict(t, "in-45301-kcp", "127.0.0.1", 45301, model.VLESS, legacyXdnsStream, `{"clients":[],"decryption":"none"}`) - var legacy map[string]any - if err := json.Unmarshal([]byte(`{"tag":"in-45301-kcp","listen":"127.0.0.1","port":45301,"protocol":"vless", - "settings":{"clients":[],"decryption":"none"},"streamSettings":`+legacyXdnsStream+`}`), &legacy); err != nil { - t.Fatalf("decode legacy inbound: %v", err) - } - if err := buildGoldenInbound(t, legacy); err == nil { - t.Fatal("xray-core accepted the legacy xdns lists; the heal is no longer needed") - } - cfg, err := (&XrayService{}).GetXrayConfig() if err != nil { t.Fatalf("GetXrayConfig: %v", err) @@ -84,8 +77,74 @@ func TestGetXrayConfig_UpgradesLegacyXdnsMask(t *testing.T) { if err := json.Unmarshal(raw, &emitted); err != nil { t.Fatalf("decode emitted inbound: %v", err) } + stream, _ := emitted["streamSettings"].(map[string]any) + assertXdnsSettingsUpgraded(t, stream) assertXrayAccepts(t, "the healed xdns inbound", buildGoldenInbound(t, emitted)) return } t.Fatal("inbound in-45301-kcp not found in the generated config") } + +// A template pasted from a 26.9.30 panel after the seeder ran passes the save check, +// since the core loads that shape without error; only the build-time heal catches it. +func TestGetXrayConfig_UpgradesTemplateOutboundXdnsMask(t *testing.T) { + setupConflictDB(t) + template := `{"outbounds":[{"protocol":"freedom","tag":"direct"},{"protocol":"vless","tag":"dns-tunnel", + "settings":{"vnext":[{"address":"t.example.com","port":53,"users":[{"id":"c9f0c2d0-0000-4000-8000-000000000000","encryption":"none"}]}]}, + "streamSettings":` + legacyXdnsStream + `}]}` + if err := (&SettingService{}).saveSetting("xrayTemplateConfig", template); err != nil { + t.Fatalf("seed template: %v", err) + } + + cfg, err := (&XrayService{}).GetXrayConfig() + if err != nil { + t.Fatalf("GetXrayConfig: %v", err) + } + var outbounds []map[string]any + if err := json.Unmarshal(cfg.OutboundConfigs, &outbounds); err != nil { + t.Fatalf("decode emitted outbounds: %v", err) + } + for _, outbound := range outbounds { + if outbound["tag"] == "dns-tunnel" { + stream, _ := outbound["streamSettings"].(map[string]any) + assertXdnsSettingsUpgraded(t, stream) + return + } + } + t.Fatal("outbound dns-tunnel not found in the generated config") +} + +// A hysteria outbound pasted from a pre-26.9.9 template still carries quicParams.udpHop, +// which the core now ignores; the build heal hands it the udphop mask instead. +func TestGetXrayConfig_UpgradesTemplateOutboundUDPHop(t *testing.T) { + setupConflictDB(t) + template := `{"outbounds":[{"protocol":"freedom","tag":"direct"},{"protocol":"hysteria","tag":"hy-out", + "settings":{"version":2,"address":"hy.example.com","port":443}, + "streamSettings":{"network":"hysteria","security":"tls","hysteriaSettings":{"version":2,"auth":"a"}, + "finalmask":{"quicParams":{"udpHop":{"ports":"20000-30000","interval":"5-10"}}}}}]}` + if err := (&SettingService{}).saveSetting("xrayTemplateConfig", template); err != nil { + t.Fatalf("seed template: %v", err) + } + + cfg, err := (&XrayService{}).GetXrayConfig() + if err != nil { + t.Fatalf("GetXrayConfig: %v", err) + } + var outbounds []map[string]any + if err := json.Unmarshal(cfg.OutboundConfigs, &outbounds); err != nil { + t.Fatalf("decode emitted outbounds: %v", err) + } + for _, outbound := range outbounds { + if outbound["tag"] != "hy-out" { + continue + } + stream, _ := outbound["streamSettings"].(map[string]any) + got, _ := json.Marshal(stream["finalmask"]) + const want = `{"udp":[{"settings":{"interval":"5-10","mode":"intervalRemote","remotePorts":"20000-30000"},"type":"udphop"}]}` + if string(got) != want { + t.Fatalf("emitted finalmask\n got: %s\nwant: %s", got, want) + } + return + } + t.Fatal("outbound hy-out not found in the generated config") +} diff --git a/internal/web/service/inbound_masque_test.go b/internal/web/service/inbound_masque_test.go new file mode 100644 index 000000000..ff1688bc3 --- /dev/null +++ b/internal/web/service/inbound_masque_test.go @@ -0,0 +1,101 @@ +package service + +import ( + "encoding/json" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +const masqueTestStream = `{"network":"masque","security":"tls","masqueSettings":{"path":"/.well-known/masque/ip/*/*/"}, + "tlsSettings":{"alpn":["h3"],"certificates":[{"certificateFile":"/etc/ssl/certs/m.crt","keyFile":"/etc/ssl/private/m.key"}]}}` + +// GetXrayConfig rebuilds every inbound's users from the clients table; a MASQUE user +// emitted without its password makes xray-core refuse the whole config at startup. +func TestGetXrayConfig_EmitsMasqueUserPasswords(t *testing.T) { + setupConflictDB(t) + in := &model.Inbound{ + Tag: "in-8443-masque", Enable: true, Listen: "127.0.0.1", Port: 8443, Protocol: model.MASQUE, + Settings: `{"clients":[{"email":"ivy@example.test","password":"masque-pass-ivy","enable":true}],"address":["10.14.0.1/24"]}`, + StreamSettings: masqueTestStream, + } + if _, _, err := (&InboundService{}).AddInbound(in); err != nil { + t.Fatalf("AddInbound: %v", err) + } + + cfg, err := (&XrayService{}).GetXrayConfig() + if err != nil { + t.Fatalf("GetXrayConfig: %v", err) + } + for i := range cfg.InboundConfigs { + if cfg.InboundConfigs[i].Tag != "in-8443-masque" { + continue + } + var settings struct { + Clients []map[string]any `json:"clients"` + } + if err := json.Unmarshal(cfg.InboundConfigs[i].Settings, &settings); err != nil { + t.Fatalf("decode emitted settings: %v", err) + } + if len(settings.Clients) != 1 || settings.Clients[0]["pass"] != "masque-pass-ivy" { + t.Fatalf("emitted clients = %v, want one with pass masque-pass-ivy", settings.Clients) + } + raw, err := json.Marshal(cfg.InboundConfigs[i]) + if err != nil { + t.Fatalf("marshal emitted inbound: %v", err) + } + var emitted map[string]any + if err := json.Unmarshal(raw, &emitted); err != nil { + t.Fatalf("decode emitted inbound: %v", err) + } + if stream, _ := emitted["streamSettings"].(map[string]any); stream["network"] != "masque" { + t.Fatalf("emitted streamSettings = %v, want the stored masque transport", emitted["streamSettings"]) + } + assertXrayAccepts(t, "the emitted MASQUE inbound", buildGoldenInbound(t, emitted)) + return + } + t.Fatal("inbound in-8443-masque not found in the generated config") +} + +// A client added to a MASQUE inbound without a password could never authenticate, +// and xray-core refuses the whole inbound over one empty pass. +func TestFillProtocolDefaults_MintsMasquePassword(t *testing.T) { + client := model.Client{Email: "ivy@example.test"} + if err := (&ClientService{}).fillProtocolDefaults(&client, &model.Inbound{Protocol: model.MASQUE}); err != nil { + t.Fatalf("fillProtocolDefaults: %v", err) + } + if len(client.Password) != 32 { + t.Fatalf("Password = %q, want a minted 32-character password", client.Password) + } + kept := model.Client{Email: "ivy@example.test", Password: "chosen"} + if err := (&ClientService{}).fillProtocolDefaults(&kept, &model.Inbound{Protocol: model.MASQUE}); err != nil { + t.Fatalf("fillProtocolDefaults: %v", err) + } + if kept.Password != "chosen" { + t.Fatalf("Password = %q, want the chosen password kept", kept.Password) + } +} + +// Editing a MASQUE client must key on its password like trojan; falling back to the +// empty UUID refused every MASQUE client edit with "empty client ID". +func TestUpdateInboundClient_MasquePasswordChange(t *testing.T) { + setupBulkDB(t) + svc := &ClientService{} + source := []model.Client{{Email: "ivy@x", Password: "pw-old", SubID: "sub-ivy", Enable: true}} + ib := mkInbound(t, 22101, model.MASQUE, clientsSettings(t, source)) + if err := svc.SyncInbound(nil, ib.Id, source); err != nil { + t.Fatalf("seed linkage: %v", err) + } + + updated := []model.Client{{Email: "ivy@x", Password: "pw-new", SubID: "sub-ivy", Enable: true}} + if _, err := svc.UpdateInboundClient(&InboundService{}, &model.Inbound{ + Id: ib.Id, + Settings: clientsSettings(t, updated), + }, "ivy@x"); err != nil { + t.Fatalf("UpdateInboundClient: %v", err) + } + + if rec := lookupClientRecord(t, "ivy@x"); rec.Password != "pw-new" { + t.Fatalf("stored password = %q, want pw-new", rec.Password) + } +} diff --git a/internal/web/service/inbound_protocol.go b/internal/web/service/inbound_protocol.go index b13f7a383..c03ecfaa3 100644 --- a/internal/web/service/inbound_protocol.go +++ b/internal/web/service/inbound_protocol.go @@ -70,6 +70,7 @@ var nodeEligibleProtocols = map[model.Protocol]bool{ model.MTProto: true, model.AmneziaWG: true, model.TUIC: true, + model.MASQUE: true, } // nodeProtocolFirstRelease is the panel release that introduced each protocol @@ -78,6 +79,7 @@ var nodeProtocolFirstRelease = map[model.Protocol]string{ model.MTProto: "v3.5.0", model.AmneziaWG: "v3.7.0", model.TUIC: "v3.8.0", + model.MASQUE: "v3.9.1", } // checkNodeCanHostProtocol refuses assigning protocol to nodeID unless the diff --git a/internal/web/service/inbound_protocol_masque_test.go b/internal/web/service/inbound_protocol_masque_test.go new file mode 100644 index 000000000..c6df5b5ed --- /dev/null +++ b/internal/web/service/inbound_protocol_masque_test.go @@ -0,0 +1,41 @@ +package service + +import ( + "strings" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +// MASQUE is native Xray, so a node runs it, but a node panel older than the release +// that knows it would hand the clients' "password" to a core that reads "pass". +func TestCheckNodeCanHostProtocol_Masque(t *testing.T) { + setupConflictDB(t) + tests := []struct { + version string + wantErr string + }{ + {"v3.9.0", "need v3.9.1 or newer"}, + {"v3.9.1", ""}, + {"dev+1d1128cf", ""}, + } + for i, tc := range tests { + t.Run(tc.version, func(t *testing.T) { + node := &model.Node{Name: "masque-node-" + tc.version, Address: "127.0.0.1", Port: 2096 + i, PanelVersion: tc.version} + if err := database.GetDB().Create(node).Error; err != nil { + t.Fatalf("create node: %v", err) + } + err := checkNodeCanHostProtocol(database.GetDB(), node.Id, model.MASQUE) + if tc.wantErr == "" { + if err != nil { + t.Fatalf("node on %s refused MASQUE: %v", tc.version, err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("node on %s: err = %v, want one containing %q", tc.version, err, tc.wantErr) + } + }) + } +} diff --git a/internal/web/service/integration/warp.go b/internal/web/service/integration/warp.go index 86e7d6afc..4c77e8f42 100644 --- a/internal/web/service/integration/warp.go +++ b/internal/web/service/integration/warp.go @@ -3,9 +3,16 @@ package integration import ( "bytes" "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/base64" "encoding/json" + "encoding/pem" "fmt" "io" + "net" "net/http" "os" "time" @@ -30,6 +37,12 @@ const ( // (not a const) so integration tests can point it at a mock server. var warpAPIBase = "https://api.cloudflareclient.com/v0a4005" +// warpMasqueAPIBase and warpMasqueClientVer are the API revision and client the WARP +// apps enroll secp256r1 MASQUE keys with; a var so tests can point it at a mock. +var warpMasqueAPIBase = "https://api.cloudflareclient.com/v0a4471" + +const warpMasqueClientVer = "a-6.35-4471" + func (s *WarpService) GetWarpData() (string, error) { return s.GetWarp() } @@ -134,6 +147,119 @@ func (s *WarpService) RegWarp(secretKey string, publicKey string) (string, error return string(result), nil } +// RegWarpMasque registers a WARP device of its own and enrolls an ECDSA P-256 key for +// MASQUE: enrolling replaces a device's WireGuard key, so the stored one stays untouched. +func (s *WarpService) RegWarpMasque() (string, error) { + _, wgPublicKey, err := wireguard.GenerateWireguardKeypair() + if err != nil { + return "", err + } + hostName, _ := os.Hostname() + regBody, err := json.Marshal(map[string]any{ + "key": wgPublicKey, + "tos": time.Now().UTC().Format("2006-01-02T15:04:05.000Z"), + "type": "PC", + "model": "x-ui", + "name": hostName, + }) + if err != nil { + return "", err + } + var registration struct { + ID string `json:"id"` + Token string `json:"token"` + } + if err := s.doWarpMasqueRequest(http.MethodPost, warpMasqueAPIBase+"/reg", "", regBody, ®istration); err != nil { + return "", err + } + if registration.ID == "" || registration.Token == "" { + return "", common.NewError("warp masque register: missing 'id' or 'token' in response") + } + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return "", err + } + publicDER, err := x509.MarshalPKIXPublicKey(&key.PublicKey) + if err != nil { + return "", err + } + enrollBody, err := json.Marshal(map[string]string{ + "key": base64.StdEncoding.EncodeToString(publicDER), + "key_type": "secp256r1", + "tunnel_type": "masque", + "name": hostName, + }) + if err != nil { + return "", err + } + var enrolled struct { + Config struct { + Interface struct { + Addresses struct { + V4 string `json:"v4"` + V6 string `json:"v6"` + } `json:"addresses"` + } `json:"interface"` + Peers []struct { + PublicKey string `json:"public_key"` + Endpoint struct { + V4 string `json:"v4"` + } `json:"endpoint"` + } `json:"peers"` + } `json:"config"` + } + if err := s.doWarpMasqueRequest(http.MethodPatch, warpMasqueAPIBase+"/reg/"+registration.ID, registration.Token, enrollBody, &enrolled); err != nil { + return "", err + } + if len(enrolled.Config.Peers) == 0 || enrolled.Config.Peers[0].PublicKey == "" { + return "", common.NewError("warp masque enroll: missing endpoint public key in response") + } + peer := enrolled.Config.Peers[0] + endpoint, _, err := net.SplitHostPort(peer.Endpoint.V4) + if err != nil { + endpoint = peer.Endpoint.V4 + } + privateDER, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + return "", err + } + var address []string + for _, a := range []string{enrolled.Config.Interface.Addresses.V4, enrolled.Config.Interface.Addresses.V6} { + if a != "" { + address = append(address, a) + } + } + result, err := json.MarshalIndent(map[string]any{ + "privateKey": string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privateDER})), + "publicKey": peer.PublicKey, + "address": address, + "endpoint": endpoint, + }, "", " ") + if err != nil { + return "", err + } + return string(result), nil +} + +func (s *WarpService) doWarpMasqueRequest(method, url, token string, body []byte, out any) error { + req, err := http.NewRequestWithContext(context.Background(), method, url, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("CF-Client-Version", warpMasqueClientVer) + req.Header.Set("User-Agent", "WARP for Android") + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + respBody, err := s.doWarpRequest(req) + if err != nil { + return err + } + return json.Unmarshal(respBody, out) +} + func (s *WarpService) SetWarpLicense(license string) (string, error) { warpData, err := s.loadWarpCreds() if err != nil { diff --git a/internal/web/service/integration/warp_masque_test.go b/internal/web/service/integration/warp_masque_test.go new file mode 100644 index 000000000..7f2953f3d --- /dev/null +++ b/internal/web/service/integration/warp_masque_test.go @@ -0,0 +1,136 @@ +package integration + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/xtls/xray-core/infra/conf" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/dbtest" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +func withWarpMasqueAPIBase(t *testing.T, base string) { + t.Helper() + orig := warpMasqueAPIBase + warpMasqueAPIBase = base + t.Cleanup(func() { warpMasqueAPIBase = orig }) +} + +// Enrolling an ECDSA key replaces a device's WireGuard key, so MASQUE gets a device of +// its own; the returned warp block must pair the enrolled key and load in xray-core. +func TestRegWarpMasqueEnrollsASeparateP256Device(t *testing.T) { + dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db")) + seedWarp(t, "") + + endpointKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate endpoint key: %v", err) + } + endpointDER, err := x509.MarshalPKIXPublicKey(&endpointKey.PublicKey) + if err != nil { + t.Fatalf("marshal endpoint key: %v", err) + } + endpointPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: endpointDER})) + + var enrolledKey *ecdsa.PublicKey + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/reg": + _, _ = w.Write([]byte(`{"id":"masque-device","token":"masque-token","account":{"license":""}}`)) + case r.Method == http.MethodPatch && r.URL.Path == "/reg/masque-device": + if got := r.Header.Get("Authorization"); got != "Bearer masque-token" { + t.Errorf("enroll Authorization = %q, want the new device's token", got) + } + var body map[string]string + _ = json.NewDecoder(r.Body).Decode(&body) + if body["key_type"] != "secp256r1" || body["tunnel_type"] != "masque" { + t.Errorf("enroll body = %v, want a secp256r1 key for the masque tunnel", body) + } + der, err := base64.StdEncoding.DecodeString(body["key"]) + if err != nil { + t.Errorf("enroll key is not base64: %v", err) + } + parsed, err := x509.ParsePKIXPublicKey(der) + key, ok := parsed.(*ecdsa.PublicKey) + if err != nil || !ok || key.Curve != elliptic.P256() { + t.Errorf("enroll key is not a PKIX P-256 public key: %v", err) + } + enrolledKey = key + resp, _ := json.Marshal(map[string]any{ + "id": "masque-device", + "config": map[string]any{ + "interface": map[string]any{"addresses": map[string]any{"v4": "172.16.0.2", "v6": "2606:4700:110:8a36::2"}}, + "peers": []any{map[string]any{ + "public_key": endpointPEM, + "endpoint": map[string]any{"v4": "162.159.198.1:0", "v6": "[2606:4700:103::1]:0"}, + }}, + }, + }) + _, _ = w.Write(resp) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(srv.Close) + withWarpMasqueAPIBase(t, srv.URL) + + out, err := (&WarpService{}).RegWarpMasque() + if err != nil { + t.Fatalf("RegWarpMasque: %v", err) + } + var result struct { + PrivateKey string `json:"privateKey"` + PublicKey string `json:"publicKey"` + Address []string `json:"address"` + Endpoint string `json:"endpoint"` + } + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("decode result: %v", err) + } + if result.Endpoint != "162.159.198.1" || result.PublicKey != endpointPEM { + t.Fatalf("endpoint = %q, publicKey = %q; want the enrolled peer's", result.Endpoint, result.PublicKey) + } + + block, _ := pem.Decode([]byte(result.PrivateKey)) + if block == nil { + t.Fatalf("privateKey %q is not PEM", result.PrivateKey) + } + private, err := x509.ParsePKCS8PrivateKey(block.Bytes) + ecPrivate, ok := private.(*ecdsa.PrivateKey) + if err != nil || !ok || enrolledKey == nil || !ecPrivate.PublicKey.Equal(enrolledKey) { + t.Fatal("the returned private key does not match the enrolled public key") + } + + raw, _ := json.Marshal(map[string]any{"warp": map[string]any{ + "privateKey": result.PrivateKey, "publicKey": result.PublicKey, "address": result.Address, + }}) + var masque conf.MasqueConfig + if err := json.Unmarshal(raw, &masque); err != nil { + t.Fatalf("decode masque config: %v", err) + } + if _, err := masque.Build(); err != nil { + t.Fatalf("xray-core refuses the returned warp block: %v", err) + } + + var stored model.Setting + if err := database.GetDB().Where("key = ?", "warp").First(&stored).Error; err != nil { + t.Fatalf("reload warp setting: %v", err) + } + var data map[string]string + _ = json.Unmarshal([]byte(stored.Value), &data) + if data["device_id"] != "old-device" { + t.Fatalf("stored WireGuard registration = %v, want it untouched", data) + } +} diff --git a/internal/web/service/outbound/outbound.go b/internal/web/service/outbound/outbound.go index 1e9ce2ad2..ae5e77607 100644 --- a/internal/web/service/outbound/outbound.go +++ b/internal/web/service/outbound/outbound.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net" + "slices" "strconv" "strings" "sync" @@ -225,6 +226,9 @@ func outboundTransportIsUDP(ob map[string]any) bool { if protocol, _ := ob["protocol"].(string); equalsAnyFold(protocol, "hysteria", "wireguard", "amneziawg") { return true } + if protocol, _ := ob["protocol"].(string); equalsAnyFold(protocol, "masque") { + return masqueDialsHTTP3(ob) + } if stream, ok := ob["streamSettings"].(map[string]any); ok { // The core resolves "kcp" and "mkcp" to the same mKCP transport. if n, _ := stream["network"].(string); equalsAnyFold(n, "hysteria", "kcp", "mkcp", "quic") { @@ -234,6 +238,15 @@ func outboundTransportIsUDP(ob map[string]any) bool { return false } +// masqueDialsHTTP3 mirrors the core's MASQUE client, which falls back to HTTP/2 over +// TCP only when the TLS ALPN offers h2 and not h3. +func masqueDialsHTTP3(ob map[string]any) bool { + stream, _ := ob["streamSettings"].(map[string]any) + tlsSettings, _ := stream["tlsSettings"].(map[string]any) + alpn, _ := tlsSettings["alpn"].([]any) + return !slices.Contains(alpn, any("h2")) || slices.Contains(alpn, any("h3")) +} + // equalsAnyFold mirrors the core, which lowercases a protocol id and a // transport name before it resolves either of them. func equalsAnyFold(value string, want ...string) bool { @@ -281,7 +294,7 @@ func extractOutboundEndpoints(ob map[string]any) []string { if len(out) == 0 { addServer(settings["address"], settings["port"]) } - case "hysteria": + case "hysteria", "masque": addServer(settings["address"], settings["port"]) case "trojan", "shadowsocks", "http", "socks": if servers, ok := settings["servers"].([]any); ok { diff --git a/internal/web/service/outbound/outbound_endpoints_test.go b/internal/web/service/outbound/outbound_endpoints_test.go index e76a13887..225d03487 100644 --- a/internal/web/service/outbound/outbound_endpoints_test.go +++ b/internal/web/service/outbound/outbound_endpoints_test.go @@ -52,3 +52,15 @@ func TestExtractOutboundEndpointsVLESS(t *testing.T) { }) } } + +// A MASQUE outbound names its server flat in settings, like hysteria; without it the +// outbound test has no endpoint to resolve and reports the outbound unreachable. +func TestExtractOutboundEndpointsMasque(t *testing.T) { + got := extractOutboundEndpoints(map[string]any{ + "protocol": "masque", + "settings": map[string]any{"address": "masque.example.com", "port": float64(8443)}, + }) + if want := []string{"masque.example.com:8443"}; !reflect.DeepEqual(got, want) { + t.Fatalf("extractOutboundEndpoints() = %v, want %v", got, want) + } +} diff --git a/internal/web/service/outbound/probe_protocol_case_test.go b/internal/web/service/outbound/probe_protocol_case_test.go index 8109df16f..4c187bfa8 100644 --- a/internal/web/service/outbound/probe_protocol_case_test.go +++ b/internal/web/service/outbound/probe_protocol_case_test.go @@ -51,6 +51,9 @@ func TestOutboundTransportIsUDPMatchesTheCore(t *testing.T) { {"capitalised wireguard", map[string]any{"protocol": "WireGuard"}, true}, {"upper hysteria", map[string]any{"protocol": "HYSTERIA"}, true}, {"amneziawg", map[string]any{"protocol": "amneziawg"}, true}, + // The core's MASQUE client dials HTTP/3 over QUIC unless the ALPN offers h2 without h3. + {"masque over http/3", map[string]any{"protocol": "masque", "streamSettings": map[string]any{"network": "masque"}}, true}, + {"masque pinned to http/2", map[string]any{"protocol": "masque", "streamSettings": map[string]any{"network": "masque", "tlsSettings": map[string]any{"alpn": []any{"h2"}}}}, false}, {"kcp transport", map[string]any{"streamSettings": map[string]any{"network": "kcp"}}, true}, {"kcp transport capitalised", map[string]any{"streamSettings": map[string]any{"network": "KCP"}}, true}, {"mkcp alias", map[string]any{"streamSettings": map[string]any{"network": "mkcp"}}, true}, diff --git a/internal/web/service/port_conflict.go b/internal/web/service/port_conflict.go index 03ab9cba0..b26134e20 100644 --- a/internal/web/service/port_conflict.go +++ b/internal/web/service/port_conflict.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "net" + "slices" "strings" "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" @@ -30,6 +31,8 @@ func inboundTransports(protocol model.Protocol, streamSettings, settings string) return transportUDP case model.MTProto: return transportTCP + case model.MASQUE: + return masqueTransports(streamSettings) } var bits transportBits @@ -93,6 +96,26 @@ func inboundTransports(protocol model.Protocol, streamSettings, settings string) return bits } +// masqueTransports mirrors xray-core's MASQUE listener: HTTP/2 on TCP when the TLS +// ALPN offers h2, HTTP/3 on UDP when it offers h3 or does not offer h2. +func masqueTransports(streamSettings string) transportBits { + var stream struct { + TLSSettings struct { + ALPN []string `json:"alpn"` + } `json:"tlsSettings"` + } + _ = json.Unmarshal([]byte(streamSettings), &stream) + h2 := slices.Contains(stream.TLSSettings.ALPN, "h2") + var bits transportBits + if h2 { + bits |= transportTCP + } + if !h2 || slices.Contains(stream.TLSSettings.ALPN, "h3") { + bits |= transportUDP + } + return bits +} + // bindAddr is a listen address plus sockopt.v6only. xray listens on "tcp"/"udp", // so Go opens every wildcard, 0.0.0.0 included, dual-stack unless v6only is set. type bindAddr struct { diff --git a/internal/web/service/port_conflict_test.go b/internal/web/service/port_conflict_test.go index c3a1fe356..d65cb1791 100644 --- a/internal/web/service/port_conflict_test.go +++ b/internal/web/service/port_conflict_test.go @@ -75,6 +75,13 @@ func TestInboundTransports(t *testing.T) { {"hysteria forced udp", model.Hysteria, `{"network":"tcp"}`, ``, transportUDP}, {"wireguard forced udp", model.WireGuard, ``, ``, transportUDP}, + // MASQUE serves HTTP/3 on UDP unless the TLS ALPN offers only h2, and adds an + // HTTP/2 listener on TCP whenever h2 is offered. + {"masque without alpn is udp", model.MASQUE, `{"network":"masque","tlsSettings":{}}`, ``, transportUDP}, + {"masque h3 is udp", model.MASQUE, `{"network":"masque","tlsSettings":{"alpn":["h3"]}}`, ``, transportUDP}, + {"masque h2 is tcp", model.MASQUE, `{"network":"masque","tlsSettings":{"alpn":["h2"]}}`, ``, transportTCP}, + {"masque h3+h2 is both", model.MASQUE, `{"network":"masque","tlsSettings":{"alpn":["h3","h2"]}}`, ``, transportTCP | transportUDP}, + {"shadowsocks tcp,udp", model.Shadowsocks, ``, `{"network":"tcp,udp"}`, transportTCP | transportUDP}, {"shadowsocks udp only", model.Shadowsocks, ``, `{"network":"udp"}`, transportUDP}, {"shadowsocks tcp only", model.Shadowsocks, ``, `{"network":"tcp"}`, transportTCP}, diff --git a/internal/web/service/xray.go b/internal/web/service/xray.go index cd297c7c8..654d57c15 100644 --- a/internal/web/service/xray.go +++ b/internal/web/service/xray.go @@ -180,10 +180,10 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) { xrayConfig.API = ensureAPIServices(xrayConfig.API) xrayConfig.Policy = ensureStatsPolicy(xrayConfig.Policy) xrayConfig.RouterConfig = stripDisabledRules(xrayConfig.RouterConfig) - // Template outbounds authored before the xray-core #6258 XHTTP rename may - // still carry sessionPlacement/sessionKey; lift them too (same reason as - // the per-inbound lift below). - xrayConfig.OutboundConfigs = liftOutboundsXhttpSessionIDKeys(xrayConfig.OutboundConfigs) + // A pasted or restored template can still carry pre-#6258 XHTTP session keys, a pre-26.10.10 + // xdns mask or a pre-26.9.9 quicParams.udpHop, all of which the core silently ignores. + xrayConfig.OutboundConfigs = healOutboundStreams(xrayConfig.OutboundConfigs, + liftXhttpSessionIDKeys, upgradeStreamLegacyXdns, upgradeStreamLegacyUDPHop) // Bridge amneziawg outbounds before anything else reads OutboundConfigs; // the core has no amneziawg proxy and would reject the raw entry. if err := transformAmneziaWGOutbounds(xrayConfig); err != nil { @@ -282,6 +282,10 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) { if c.Auth != "" { entry["auth"] = c.Auth } + case model.MASQUE: + if c.Password != "" { + entry["password"] = c.Password + } case model.WireGuard: if inboundClient, ok := wireguardClientsByEmail[strings.ToLower(strings.TrimSpace(c.Email))]; ok { c.AllowedIPs = inboundClient.AllowedIPs @@ -376,7 +380,7 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) { logger.Warningf("Inbound %q: dropping %d XMC finalmask mask(s) without complete Minecraft profiles — reconfigure them to restore the obfuscation (see XTLS/Xray-core#6487)", inbound.Tag, dropped) } - // A row that skipped the save path can still carry the pre-26.9.30 xdns lists. + // A row that skipped the save path can still carry an xdns shape older than 26.10.10. maskcompat.UpgradeLegacyXdns(stream["finalmask"]) // xray-core v26.6.22 (#6258) renamed the XHTTP session keys and @@ -1712,11 +1716,9 @@ func liftXhttpSessionIDKeys(stream map[string]any) bool { return changed } -// liftOutboundsXhttpSessionIDKeys applies liftXhttpSessionIDKeys to every -// outbound's streamSettings in the raw outbounds array. The original bytes are -// returned untouched when nothing needs lifting, so an unchanged config never -// looks modified to the hot-reload diff. -func liftOutboundsXhttpSessionIDKeys(raw json_util.RawMessage) json_util.RawMessage { +// healOutboundStreams applies every heal to each outbound's streamSettings, returning the +// original bytes when none changed anything so the hot-reload diff sees no edit. +func healOutboundStreams(raw json_util.RawMessage, heals ...func(stream map[string]any) bool) json_util.RawMessage { if len(raw) == 0 { return raw } @@ -1727,8 +1729,10 @@ func liftOutboundsXhttpSessionIDKeys(raw json_util.RawMessage) json_util.RawMess changed := false for _, ob := range outbounds { if stream, ok := ob["streamSettings"].(map[string]any); ok { - if liftXhttpSessionIDKeys(stream) { - changed = true + for _, heal := range heals { + if heal(stream) { + changed = true + } } } } @@ -1740,3 +1744,11 @@ func liftOutboundsXhttpSessionIDKeys(raw json_util.RawMessage) json_util.RawMess } return raw } + +func upgradeStreamLegacyXdns(stream map[string]any) bool { + return maskcompat.UpgradeLegacyXdns(stream["finalmask"]) +} + +func upgradeStreamLegacyUDPHop(stream map[string]any) bool { + return maskcompat.UpgradeLegacyUDPHop(stream["finalmask"]) +} diff --git a/internal/web/service/xray_xhttp_session_test.go b/internal/web/service/xray_xhttp_session_test.go index a122f99ca..800009481 100644 --- a/internal/web/service/xray_xhttp_session_test.go +++ b/internal/web/service/xray_xhttp_session_test.go @@ -57,9 +57,9 @@ func TestLiftXhttpSessionIDKeys(t *testing.T) { }) } -func TestLiftOutboundsXhttpSessionIDKeys(t *testing.T) { +func TestHealOutboundStreams_LiftsXhttpSessionIDKeys(t *testing.T) { raw := json_util.RawMessage(`[{"protocol":"vless","streamSettings":{"network":"xhttp","xhttpSettings":{"sessionKey":"x_session","sessionPlacement":"query"}}}]`) - out := liftOutboundsXhttpSessionIDKeys(raw) + out := healOutboundStreams(raw, liftXhttpSessionIDKeys) var parsed []map[string]any if err := json.Unmarshal(out, &parsed); err != nil { @@ -75,7 +75,7 @@ func TestLiftOutboundsXhttpSessionIDKeys(t *testing.T) { // Unchanged input must return byte-identical output (no spurious hot-reload). clean := json_util.RawMessage(`[{"protocol":"freedom"}]`) - if got := liftOutboundsXhttpSessionIDKeys(clean); string(got) != string(clean) { + if got := healOutboundStreams(clean, liftXhttpSessionIDKeys); string(got) != string(clean) { t.Fatalf("clean outbounds were rewritten: %s", got) } } diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index 91a462c9c..63c4f7313 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -694,7 +694,41 @@ "bytesPerSecTip": "حد السرعة (بايت/ثانية) المطبّق على مرور الـ fallback بعد العتبة، عشان الفحوصات ماتقدرش تستخدم سيرفرك كنطاق ترددي مجاني للهدف. 0 = بلا حد (بيعطّل الاتجاه ده).", "burstBytesPerSec": "بايت في الثانية للدفقة", "burstBytesPerSecTip": "السماح بدفقات قصيرة فوق المعدل الثابت (حجم token-bucket). لو أقل من بايت في الثانية بيترفع ليطابقه.", - "autoFill": "الملء التلقائي" + "autoFill": "الملء التلقائي", + "xdrive": { + "service": "خدمة التخزين", + "serviceLocal": "مجلد محلي", + "serviceTemplate": "قالب مخصص", + "remoteFolder": "المجلد البعيد", + "clientId": "Client ID", + "clientSecret": "Client Secret", + "refreshToken": "Refresh Token", + "secrets": "الأسرار", + "secretsDesc": "قيم يقرؤها القالب باسم {secret0} و{secret1} …", + "template": "قالب التخزين", + "templateInvalid": "يجب أن يكون كائن JSON", + "front": "عنوان الواجهة (Front)", + "frontDesc": "مضيف أو IP اختياري يُتصل عبره بواجهة API التخزين (domain fronting).", + "frontPort": "منفذ الواجهة (Front)", + "segmentBytes": "حجم المقطع (بايت)", + "flushInterval": "فاصل الإرسال (ms)", + "pollInterval": "فاصل الاستطلاع (ms)", + "maxPollInterval": "أقصى فاصل استطلاع (ms)", + "eagerWindow": "نافذة الإرسال المبكر (ms)", + "holeTimeout": "مهلة المقطع المفقود (ms)", + "sessionTtl": "مدة صلاحية الجلسة (s)", + "concurrency": "التزامن" + }, + "masque": { + "addressPool": "مجمّع العناوين", + "addressPoolDesc": "تُخصَّص عناوين النفق من هذه البادئات — بحد أقصى IPv4 واحد و IPv6 واحد." + }, + "tunDesc": "نوع المحوّل", + "tunDescTooltip": "على ويندوز بس: نوع النفق اللي بيتعمل بيه محوّل Wintun جديد. لو فاضي يبقى Wintun.", + "autoSystemDnsToGateway": "DNS النظام إلى البوابة", + "autoSystemDnsToGatewayTooltip": "على لينكس بس: بيوجّه systemd-resolved لبوابة TUN عشان استعلامات النظام تعدّي من Xray. محتاج بوابة، والـTUN مش هيشتغل لو ده فشل.", + "autoSystemWfpBlockLeak": "منع التسريب (WFP)", + "autoSystemWfpBlockLeakTooltip": "على ويندوز بس، مع Auto system routes: ‏dns بيخلّي DNS جوّه الـTUN (محتاج سيرفرات DNS)، وmisconfigtun بيمنع نسخة IP مفيش مسار بيوديها للـTUN." }, "info": { "mode": "الوضع", @@ -789,7 +823,7 @@ "limitIpFail2banWindows": "Fail2ban غير متوفّر على نظام Windows، لذا لا يمكن تطبيق حد عناوين IP.", "limitIpDisabled": "ميزة حد عناوين IP معطّلة على هذا الخادم.", "password": "كلمة المرور", - "passwordDesc": "تُستخدم فقط من قبل عملاء Trojan و Shadowsocks؛ ويتم تجاهلها لـ VLESS و VMess و Hysteria و WireGuard.", + "passwordDesc": "يستخدمها عملاء Trojan و Shadowsocks و TUIC و MASQUE؛ ويتم تجاهلها لـ VLESS و VMess و Hysteria و WireGuard.", "subId": "معرّف الاشتراك", "subIdDesc": "يُستخدم أيضًا كرمز دعوة لهذا العميل في بوت تيليجرام: من يرسله إلى البوت يُربط بهذا العميل. اجعله طويلًا وعشوائيًا — فالمعرّف القصير أو السهل التخمين قد يستولي عليه شخص آخر.", "online": "متصل", @@ -1932,6 +1966,7 @@ "serverNamePlaceholder": "اسم الخادم", "verifyPeerName": "التحقق من اسم peer", "pinnedSha256": "SHA256 مثبت", + "useSystemCA": "استخدام CA النظام", "shortId": "Short ID", "sockopts": "Sockopts", "keepAliveInterval": "فاصل keep alive", @@ -1939,7 +1974,10 @@ "interface": "الواجهة", "proxyProtocol": "Proxy protocol", "tcpUserTimeoutMs": "TCP user timeout (ms)", - "tcpKeepAliveIdleS": "TCP keep-alive idle (ثانية)" + "tcpKeepAliveIdleS": "TCP keep-alive idle (ثانية)", + "masqueWarpDesc": "الاتصال بـ Cloudflare WARP عبر MASQUE بمفتاح ECDSA P-256 مسجَّل؛ يكون host و path افتراضيًا نقطة نهاية Cloudflare.", + "masqueWarpPublicKey": "المفتاح العام لنقطة النهاية", + "masqueWarpAddress": "عناوين النفق" }, "outbound": { "tag": "الوسم", @@ -2200,7 +2238,9 @@ "warpPlusData": "بيانات WARP+", "quota": "الحصة", "usage": "الاستخدام", - "addOutbound": "إضافة صادر" + "addOutbound": "إضافة صادر", + "masqueDesc": "يسجّل جهاز WARP منفصلًا مخصصًا لـ MASQUE؛ ولا يتأثر تسجيل WireGuard أعلاه.", + "addMasqueOutbound": "إضافة outbound لـ WARP عبر MASQUE" }, "dns": { "enable": "فعل DNS", @@ -2244,7 +2284,11 @@ "clearAll": "حذف الكل", "clearAllTitle": "حذف جميع خوادم DNS؟", "clearAllConfirm": "سيؤدي هذا إلى إزالة جميع خوادم DNS من القائمة. لا يمكن التراجع عن هذا الإجراء.", - "dnsLeakWarning": "قد يتسرب DNS عبر localhost أو UDP/TCP غير المشفر أو وضع DoH/DoQ المحلي أو استعلامات الرجوع أو EDNS client IP. استخدم DoH عبر التوجيه، وثبت أسماء المحللات في hosts، وعطل الرجوع عندما تكون الخصوصية مهمة." + "dnsLeakWarning": "قد يتسرب DNS عبر localhost أو UDP/TCP غير المشفر أو وضع DoH/DoQ المحلي أو استعلامات الرجوع أو EDNS client IP. استخدم DoH عبر التوجيه، وثبت أسماء المحللات في hosts، وعطل الرجوع عندما تكون الخصوصية مهمة.", + "script": "سكريبت Lua", + "scriptDesc": "مسار ملف ‎.lua‎ بيعرّف HandleDNSQuery، مطلق أو نسبةً لمجلد bin بتاع Xray. بعدها السكريبت هو اللي بيرد على كل الاستعلامات وما بيوصلش للسيرفرات غير من خلال الكود بتاعه. Xray مش هيشتغل لو الملف مش موجود أو مش صالح.", + "serverId": "المعرّف", + "serverIdDesc": "الاسم اللي سكريبت DNS بلغة Lua بيستخدمه عشان يختار السيرفر ده" }, "fakedns": { "add": "أضف Fake DNS", @@ -2252,7 +2296,10 @@ "poolSize": "حجم المجموعة" }, "defaultOutbound": "الصادر الافتراضي", - "defaultOutboundDesc": "الحركة التي لا تطابق أي قاعدة توجيه تستخدم هذا الصادر (الأول في القائمة)." + "defaultOutboundDesc": "الحركة التي لا تطابق أي قاعدة توجيه تستخدم هذا الصادر (الأول في القائمة).", + "routingScript": "سكريبت التوجيه", + "routingScriptDesc": "مسار ملف ‎.lua‎ بيعرّف HandleRoute، مطلق أو نسبةً لمجلد bin بتاع Xray. بعدها السكريبت هو اللي بيختار كل مسار وXray بيتجاهل كل قواعد التوجيه، ومنها قاعدة api بتاعة البانل: لازم توجّه الإدخال api للإخراج api وإلا إحصائيات الترافيك وتطبيق تغييرات العملاء مباشرةً هيقفوا. Xray مش هيشتغل لو الملف مش موجود أو مش صالح.", + "routingScriptActive": "فيه سكريبت توجيه متظبط في {section}، عشان كده Xray بيتجاهل القواعد دي." }, "hosts": { "addHost": "إضافة مضيف", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index 55aa04263..e8d24ec5a 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -695,7 +695,41 @@ "node": "Node address", "listen": "Inbound listen", "custom": "Custom" - } + }, + "xdrive": { + "service": "Storage service", + "serviceLocal": "Local folder", + "serviceTemplate": "Custom template", + "remoteFolder": "Remote folder", + "clientId": "Client ID", + "clientSecret": "Client secret", + "refreshToken": "Refresh token", + "secrets": "Secrets", + "secretsDesc": "Values the template reads as {secret0}, {secret1}, …", + "template": "Storage template", + "templateInvalid": "Must be a JSON object", + "front": "Front address", + "frontDesc": "Optional host or IP the storage API is dialed through (domain fronting).", + "frontPort": "Front port", + "segmentBytes": "Segment size (bytes)", + "flushInterval": "Flush interval (ms)", + "pollInterval": "Poll interval (ms)", + "maxPollInterval": "Max poll interval (ms)", + "eagerWindow": "Eager window (ms)", + "holeTimeout": "Missing segment timeout (ms)", + "sessionTtl": "Session TTL (s)", + "concurrency": "Concurrency" + }, + "masque": { + "addressPool": "Address pool", + "addressPoolDesc": "Tunnel addresses are leased from these prefixes — at most one IPv4 and one IPv6." + }, + "tunDesc": "Adapter type", + "tunDescTooltip": "Windows only: the tunnel type a new Wintun adapter is created with. Empty means Wintun.", + "autoSystemDnsToGateway": "System DNS to gateway", + "autoSystemDnsToGatewayTooltip": "Linux only: points systemd-resolved at the TUN gateway so system lookups go through Xray. Needs a gateway, and the TUN does not start if this fails.", + "autoSystemWfpBlockLeak": "Block leaks (WFP)", + "autoSystemWfpBlockLeakTooltip": "Windows only, with Auto system routes: dns keeps DNS inside the TUN (needs DNS servers), misconfigtun blocks an IP version that no route sends into the TUN." }, "info": { "mode": "Mode", @@ -789,7 +823,7 @@ "limitIpFail2banWindows": "Fail2ban is not available on Windows, so the IP limit cannot be enforced.", "limitIpDisabled": "The IP limit feature is disabled on this server.", "password": "Password", - "passwordDesc": "Used by Trojan, Shadowsocks, and TUIC clients; ignored for VLESS, VMess, Hysteria, and WireGuard.", + "passwordDesc": "Used by Trojan, Shadowsocks, TUIC, and MASQUE clients; ignored for VLESS, VMess, Hysteria, and WireGuard.", "subId": "Subscription ID", "subIdDesc": "Also works as this client's Telegram bot invite code: whoever sends it to the bot is linked to this client. Keep it long and random — a short or guessable ID can be claimed by someone else.", "online": "Online", @@ -2050,6 +2084,7 @@ "serverNamePlaceholder": "server name", "verifyPeerName": "Verify peer name", "pinnedSha256": "Pinned SHA256", + "useSystemCA": "Use system CA", "shortId": "Short ID", "sockopts": "Sockopts", "keepAliveInterval": "Keep alive interval", @@ -2057,7 +2092,10 @@ "interface": "Interface", "proxyProtocol": "Proxy protocol", "tcpUserTimeoutMs": "TCP user timeout (ms)", - "tcpKeepAliveIdleS": "TCP keep-alive idle (s)" + "tcpKeepAliveIdleS": "TCP keep-alive idle (s)", + "masqueWarpDesc": "Connect to Cloudflare WARP over MASQUE with an enrolled ECDSA P-256 key; host and path default to Cloudflare's endpoint.", + "masqueWarpPublicKey": "Endpoint public key", + "masqueWarpAddress": "Tunnel addresses" }, "outbound": { "tag": "Tag", @@ -2318,7 +2356,9 @@ "warpPlusData": "WARP+ data", "quota": "Quota", "usage": "Usage", - "addOutbound": "Add outbound" + "addOutbound": "Add outbound", + "masqueDesc": "Registers a separate WARP device enrolled for MASQUE; the WireGuard registration above is not affected.", + "addMasqueOutbound": "Add WARP over MASQUE outbound" }, "dns": { "enable": "Enable DNS", @@ -2362,7 +2402,11 @@ "clearAll": "Delete All", "clearAllTitle": "Delete all DNS servers?", "clearAllConfirm": "This removes every DNS server from the list. This cannot be undone.", - "dnsLeakWarning": "DNS can leak through localhost, plain UDP/TCP, local-mode DoH/DoQ, fallback queries, or EDNS client IP. Use routed DoH, hosts pins, and disable fallback when privacy matters." + "dnsLeakWarning": "DNS can leak through localhost, plain UDP/TCP, local-mode DoH/DoQ, fallback queries, or EDNS client IP. Use routed DoH, hosts pins, and disable fallback when privacy matters.", + "script": "Lua Script", + "scriptDesc": "Path to a .lua file defining HandleDNSQuery, absolute or relative to the Xray bin folder. The script then answers every query and reaches the servers only through its own code. Xray will not start if the file is missing or invalid.", + "serverId": "ID", + "serverIdDesc": "The name a Lua DNS script uses to pick this server" }, "fakedns": { "add": "Add Fake DNS", @@ -2370,7 +2414,10 @@ "poolSize": "Pool Size" }, "defaultOutbound": "Default Outbound", - "defaultOutboundDesc": "Traffic that does not match any routing rule uses this outbound (Xray uses the first outbound in the list)." + "defaultOutboundDesc": "Traffic that does not match any routing rule uses this outbound (Xray uses the first outbound in the list).", + "routingScript": "Routing Script", + "routingScriptDesc": "Path to a .lua file defining HandleRoute, absolute or relative to the Xray bin folder. The script then picks every route and Xray ignores all routing rules, the panel's api rule included: send the api inbound to the api outbound or traffic stats and live client changes stop. Xray will not start if the file is missing or invalid.", + "routingScriptActive": "A routing script is set under {section}, so Xray ignores these rules." }, "sponsors": { "title": "Sponsors", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index cb7af09bc..a59da226b 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -694,7 +694,41 @@ "listen": "Dirección de escucha del inbound", "custom": "Personalizada" }, - "autoFill": "Autocompletar" + "autoFill": "Autocompletar", + "xdrive": { + "service": "Servicio de almacenamiento", + "serviceLocal": "Carpeta local", + "serviceTemplate": "Plantilla personalizada", + "remoteFolder": "Carpeta remota", + "clientId": "Client ID", + "clientSecret": "Client Secret", + "refreshToken": "Refresh Token", + "secrets": "Secretos", + "secretsDesc": "Valores que la plantilla lee como {secret0}, {secret1}, …", + "template": "Plantilla de almacenamiento", + "templateInvalid": "Debe ser un objeto JSON", + "front": "Dirección de fronting", + "frontDesc": "Host o IP opcional a través del cual se conecta a la API de almacenamiento (domain fronting).", + "frontPort": "Puerto de fronting", + "segmentBytes": "Tamaño de segmento (bytes)", + "flushInterval": "Intervalo de envío (ms)", + "pollInterval": "Intervalo de sondeo (ms)", + "maxPollInterval": "Intervalo máximo de sondeo (ms)", + "eagerWindow": "Ventana anticipada (ms)", + "holeTimeout": "Tiempo de espera de segmento perdido (ms)", + "sessionTtl": "TTL de sesión (s)", + "concurrency": "Concurrencia" + }, + "masque": { + "addressPool": "Pool de direcciones", + "addressPoolDesc": "Las direcciones del túnel se asignan de estos prefijos: como máximo uno IPv4 y uno IPv6." + }, + "tunDesc": "Tipo de adaptador", + "tunDescTooltip": "Solo Windows: el tipo de túnel con el que se crea un adaptador Wintun nuevo. Vacío equivale a Wintun.", + "autoSystemDnsToGateway": "DNS del sistema a la puerta de enlace", + "autoSystemDnsToGatewayTooltip": "Solo Linux: apunta systemd-resolved a la puerta de enlace del TUN para que las consultas del sistema pasen por Xray. Necesita una puerta de enlace y el TUN no arranca si esto falla.", + "autoSystemWfpBlockLeak": "Bloquear fugas (WFP)", + "autoSystemWfpBlockLeakTooltip": "Solo Windows, con Auto system routes: dns mantiene el DNS dentro del TUN (necesita servidores DNS) y misconfigtun bloquea una versión de IP que ninguna ruta envía al TUN." }, "info": { "mode": "Modo", @@ -789,7 +823,7 @@ "limitIpFail2banWindows": "Fail2ban no está disponible en Windows, por lo que no se puede aplicar el límite de IP.", "limitIpDisabled": "La función de límite de IP está deshabilitada en este servidor.", "password": "Contraseña", - "passwordDesc": "Solo la usan los clientes Trojan y Shadowsocks; se ignora para VLESS, VMess, Hysteria y WireGuard.", + "passwordDesc": "La usan los clientes Trojan, Shadowsocks, TUIC y MASQUE; se ignora para VLESS, VMess, Hysteria y WireGuard.", "subId": "ID de suscripción", "subIdDesc": "También sirve como código de invitación de este cliente en el bot de Telegram: quien lo envíe al bot queda vinculado a este cliente. Mantenlo largo y aleatorio: un ID corto o fácil de adivinar puede ser reclamado por otra persona.", "online": "En línea", @@ -1932,6 +1966,7 @@ "serverNamePlaceholder": "nombre del servidor", "verifyPeerName": "Verificar nombre del peer", "pinnedSha256": "SHA256 pinned", + "useSystemCA": "Usar CA del sistema", "shortId": "Short ID", "sockopts": "Sockopts", "keepAliveInterval": "Intervalo keep alive", @@ -1939,7 +1974,10 @@ "interface": "Interfaz", "proxyProtocol": "Proxy protocol", "tcpUserTimeoutMs": "TCP user timeout (ms)", - "tcpKeepAliveIdleS": "TCP keep-alive idle (s)" + "tcpKeepAliveIdleS": "TCP keep-alive idle (s)", + "masqueWarpDesc": "Conecta a Cloudflare WARP mediante MASQUE con una clave ECDSA P-256 registrada; host y path usan por defecto el endpoint de Cloudflare.", + "masqueWarpPublicKey": "Clave pública del endpoint", + "masqueWarpAddress": "Direcciones del túnel" }, "outbound": { "tag": "Etiqueta", @@ -2200,7 +2238,9 @@ "warpPlusData": "Datos WARP+", "quota": "Cuota", "usage": "Uso", - "addOutbound": "Añadir salida" + "addOutbound": "Añadir salida", + "masqueDesc": "Registra un dispositivo WARP aparte inscrito para MASQUE; el registro de WireGuard de arriba no se ve afectado.", + "addMasqueOutbound": "Añadir outbound de WARP sobre MASQUE" }, "dns": { "enable": "Habilitar DNS", @@ -2244,7 +2284,11 @@ "clearAll": "Eliminar todos", "clearAllTitle": "¿Eliminar todos los servidores DNS?", "clearAllConfirm": "Esto eliminará todos los servidores DNS de la lista. No se puede deshacer.", - "dnsLeakWarning": "El DNS puede filtrarse por localhost, UDP/TCP sin cifrar, DoH/DoQ en modo local, consultas de respaldo o EDNS client IP. Usa DoH enrutado, fija los resolutores en hosts y desactiva el respaldo cuando la privacidad importe." + "dnsLeakWarning": "El DNS puede filtrarse por localhost, UDP/TCP sin cifrar, DoH/DoQ en modo local, consultas de respaldo o EDNS client IP. Usa DoH enrutado, fija los resolutores en hosts y desactiva el respaldo cuando la privacidad importe.", + "script": "Script Lua", + "scriptDesc": "Ruta a un archivo .lua que define HandleDNSQuery, absoluta o relativa a la carpeta bin de Xray. El script responde entonces a cada consulta y solo llega a los servidores a través de su propio código. Xray no arrancará si el archivo falta o no es válido.", + "serverId": "ID", + "serverIdDesc": "Nombre con el que un script DNS en Lua elige este servidor" }, "fakedns": { "add": "Agregar DNS Falso", @@ -2252,7 +2296,10 @@ "poolSize": "Tamaño del grupo" }, "defaultOutbound": "Salida predeterminada", - "defaultOutboundDesc": "El tráfico que no coincide con ninguna regla de enrutamiento usa esta salida (la primera de la lista)." + "defaultOutboundDesc": "El tráfico que no coincide con ninguna regla de enrutamiento usa esta salida (la primera de la lista).", + "routingScript": "Script de enrutamiento", + "routingScriptDesc": "Ruta a un archivo .lua que define HandleRoute, absoluta o relativa a la carpeta bin de Xray. El script elige entonces cada ruta y Xray ignora todas las reglas de enrutamiento, incluida la regla api del panel: envía el inbound api al outbound api o se detendrán las estadísticas de tráfico y los cambios de clientes en vivo. Xray no arrancará si el archivo falta o no es válido.", + "routingScriptActive": "Hay un script de enrutamiento definido en {section}, así que Xray ignora estas reglas." }, "hosts": { "addHost": "Agregar host", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index d99f992ad..96dac215e 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -694,7 +694,41 @@ "listen": "آدرس شنود ورودی", "custom": "سفارشی" }, - "autoFill": "تکمیل خودکار" + "autoFill": "تکمیل خودکار", + "xdrive": { + "service": "سرویس ذخیره‌سازی", + "serviceLocal": "پوشهٔ محلی", + "serviceTemplate": "قالب سفارشی", + "remoteFolder": "پوشهٔ راه دور", + "clientId": "Client ID", + "clientSecret": "Client Secret", + "refreshToken": "Refresh Token", + "secrets": "مقادیر محرمانه", + "secretsDesc": "مقادیری که قالب با {secret0}، {secret1}، … می‌خواند", + "template": "قالب ذخیره‌سازی", + "templateInvalid": "باید یک شیء JSON باشد", + "front": "آدرس Front", + "frontDesc": "میزبان یا IP اختیاری که API ذخیره‌ساز از طریق آن وصل می‌شود (domain fronting).", + "frontPort": "پورت Front", + "segmentBytes": "اندازهٔ قطعه (بایت)", + "flushInterval": "فاصلهٔ ارسال (میلی‌ثانیه)", + "pollInterval": "فاصلهٔ بررسی (میلی‌ثانیه)", + "maxPollInterval": "حداکثر فاصلهٔ بررسی (میلی‌ثانیه)", + "eagerWindow": "پنجرهٔ پیش‌ارسال (میلی‌ثانیه)", + "holeTimeout": "مهلت قطعهٔ گمشده (میلی‌ثانیه)", + "sessionTtl": "TTL نشست (ثانیه)", + "concurrency": "هم‌زمانی" + }, + "masque": { + "addressPool": "مخزن آدرس", + "addressPoolDesc": "آدرس‌های تونل از این پیشوندها تخصیص داده می‌شوند — حداکثر یک IPv4 و یک IPv6." + }, + "tunDesc": "نوع آداپتور", + "tunDescTooltip": "فقط ویندوز: نوع تونلی که آداپتور Wintun جدید با آن ساخته می‌شود. خالی یعنی Wintun.", + "autoSystemDnsToGateway": "DNS سیستم به گیت‌وی", + "autoSystemDnsToGatewayTooltip": "فقط لینوکس: systemd-resolved را به گیت‌وی TUN می‌برد تا کوئری‌های سیستم از Xray عبور کنند. به گیت‌وی نیاز دارد و اگر این کار شکست بخورد TUN اجرا نمی‌شود.", + "autoSystemWfpBlockLeak": "جلوگیری از نشت (WFP)", + "autoSystemWfpBlockLeakTooltip": "فقط ویندوز، همراه با مسیریابی خودکار سیستم: dns ترافیک DNS را داخل TUN نگه می‌دارد (به سرور DNS نیاز دارد) و misconfigtun نسخه‌ای از IP را که هیچ مسیری به TUN نمی‌فرستد مسدود می‌کند." }, "info": { "mode": "حالت", @@ -789,7 +823,7 @@ "limitIpFail2banWindows": "Fail2ban روی ویندوز در دسترس نیست، بنابراین محدودیت IP قابل اعمال نیست.", "limitIpDisabled": "قابلیت محدودیت IP روی این سرور غیرفعال است.", "password": "رمز عبور", - "passwordDesc": "فقط توسط کلاینت‌های Trojan و Shadowsocks استفاده می‌شود؛ برای VLESS، VMess، Hysteria و WireGuard نادیده گرفته می‌شود.", + "passwordDesc": "توسط کلاینت‌های Trojan، Shadowsocks، TUIC و MASQUE استفاده می‌شود؛ برای VLESS، VMess، Hysteria و WireGuard نادیده گرفته می‌شود.", "subId": "شناسه اشتراک", "subIdDesc": "این شناسه، کد دعوت این کاربر در ربات تلگرام هم هست: هر کسی آن را به ربات بفرستد به این کاربر متصل می‌شود. آن را طولانی و تصادفی نگه دارید؛ شناسهٔ کوتاه یا قابل حدس ممکن است توسط شخص دیگری تصاحب شود.", "online": "آنلاین", @@ -1932,6 +1966,7 @@ "serverNamePlaceholder": "نام سرور", "verifyPeerName": "تایید نام Peer", "pinnedSha256": "SHA256 پین‌شده", + "useSystemCA": "استفاده از CA سیستم", "shortId": "Short ID", "sockopts": "Sockopts", "keepAliveInterval": "بازه Keep alive", @@ -1939,7 +1974,10 @@ "interface": "رابط", "proxyProtocol": "Proxy Protocol", "tcpUserTimeoutMs": "TCP user timeout (ms)", - "tcpKeepAliveIdleS": "TCP keep-alive idle (s)" + "tcpKeepAliveIdleS": "TCP keep-alive idle (s)", + "masqueWarpDesc": "اتصال به Cloudflare WARP از طریق MASQUE با یک کلید ECDSA P-256 ثبت‌شده؛ host و path به‌طور پیش‌فرض endpoint کلودفلر هستند.", + "masqueWarpPublicKey": "کلید عمومی endpoint", + "masqueWarpAddress": "آدرس‌های تونل" }, "outbound": { "tag": "تگ", @@ -2200,7 +2238,9 @@ "warpPlusData": "داده WARP+", "quota": "سهمیه", "usage": "مصرف", - "addOutbound": "افزودن خروجی" + "addOutbound": "افزودن خروجی", + "masqueDesc": "یک دستگاه WARP جداگانه برای MASQUE ثبت می‌کند؛ ثبت WireGuard بالا تغییری نمی‌کند.", + "addMasqueOutbound": "افزودن outbound برای WARP روی MASQUE" }, "dns": { "enable": "فعال کردن حل دامنه", @@ -2244,7 +2284,11 @@ "clearAll": "حذف همه", "clearAllTitle": "حذف همه سرورهای DNS؟", "clearAllConfirm": "این کار همه سرورهای DNS را از لیست حذف می‌کند و قابل بازگشت نیست.", - "dnsLeakWarning": "DNS ممکن است از طریق localhost، UDP/TCP ساده، حالت محلی DoH/DoQ، درخواست‌های fallback یا EDNS client IP نشت کند. برای حریم خصوصی از DoH مسیریابی‌شده، مقدارهای hosts ثابت و غیرفعال‌سازی fallback استفاده کنید." + "dnsLeakWarning": "DNS ممکن است از طریق localhost، UDP/TCP ساده، حالت محلی DoH/DoQ، درخواست‌های fallback یا EDNS client IP نشت کند. برای حریم خصوصی از DoH مسیریابی‌شده، مقدارهای hosts ثابت و غیرفعال‌سازی fallback استفاده کنید.", + "script": "اسکریپت Lua", + "scriptDesc": "مسیر یک فایل ‎.lua‎ که HandleDNSQuery را تعریف می‌کند، به‌صورت مطلق یا نسبت به پوشه‌ی bin در Xray. از آن پس اسکریپت به همه‌ی کوئری‌ها پاسخ می‌دهد و سرورها فقط از طریق کد خودش استفاده می‌شوند. اگر فایل وجود نداشته باشد یا نامعتبر باشد، Xray اجرا نمی‌شود.", + "serverId": "شناسه", + "serverIdDesc": "نامی که اسکریپت Lua‌ی DNS با آن این سرور را انتخاب می‌کند" }, "fakedns": { "add": "افزودن دی‌ان‌اس جعلی", @@ -2252,7 +2296,10 @@ "poolSize": "اندازه استخر" }, "defaultOutbound": "خروجی پیش‌فرض", - "defaultOutboundDesc": "ترافیکی که با هیچ قانون مسیریابی جور نشود از این خروجی استفاده می‌کند (اولین خروجی در فهرست)." + "defaultOutboundDesc": "ترافیکی که با هیچ قانون مسیریابی جور نشود از این خروجی استفاده می‌کند (اولین خروجی در فهرست).", + "routingScript": "اسکریپت مسیریابی", + "routingScriptDesc": "مسیر یک فایل ‎.lua‎ که HandleRoute را تعریف می‌کند، به‌صورت مطلق یا نسبت به پوشه‌ی bin در Xray. از آن پس اسکریپت همه‌ی مسیرها را انتخاب می‌کند و Xray همه‌ی قوانین مسیریابی، از جمله قانون api پنل، را نادیده می‌گیرد: اینباند api را به اوت‌باند api بفرستید، وگرنه آمار ترافیک و اعمال زنده‌ی تغییرات کلاینت‌ها متوقف می‌شود. اگر فایل وجود نداشته باشد یا نامعتبر باشد، Xray اجرا نمی‌شود.", + "routingScriptActive": "یک اسکریپت مسیریابی در بخش {section} تنظیم شده است، بنابراین Xray این قوانین را نادیده می‌گیرد." }, "hosts": { "addHost": "افزودن میزبان", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index 4a184924a..c45c8445d 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -694,7 +694,41 @@ "bytesPerSecTip": "Batas kecepatan (byte/detik) yang diterapkan pada lalu lintas fallback setelah ambang batas, agar probe tidak dapat memakai server Anda sebagai bandwidth gratis menuju target. 0 = tanpa batas (menonaktifkan arah ini).", "burstBytesPerSec": "Byte per detik burst", "burstBytesPerSecTip": "Kelonggaran untuk burst singkat di atas laju tetap (ukuran token-bucket). Jika lebih kecil dari Byte per detik, nilainya dinaikkan agar sama.", - "autoFill": "Isi Otomatis" + "autoFill": "Isi Otomatis", + "xdrive": { + "service": "Layanan penyimpanan", + "serviceLocal": "Folder lokal", + "serviceTemplate": "Template kustom", + "remoteFolder": "Folder jarak jauh", + "clientId": "Client ID", + "clientSecret": "Client Secret", + "refreshToken": "Refresh Token", + "secrets": "Rahasia", + "secretsDesc": "Nilai yang dibaca template sebagai {secret0}, {secret1}, …", + "template": "Template penyimpanan", + "templateInvalid": "Harus berupa objek JSON", + "front": "Alamat fronting", + "frontDesc": "Host atau IP opsional untuk menghubungi API penyimpanan (domain fronting).", + "frontPort": "Port fronting", + "segmentBytes": "Ukuran segmen (byte)", + "flushInterval": "Interval pengiriman (ms)", + "pollInterval": "Interval polling (ms)", + "maxPollInterval": "Interval polling maks. (ms)", + "eagerWindow": "Jendela kirim awal (ms)", + "holeTimeout": "Batas waktu segmen hilang (ms)", + "sessionTtl": "TTL sesi (s)", + "concurrency": "Konkurensi" + }, + "masque": { + "addressPool": "Kumpulan alamat", + "addressPoolDesc": "Alamat terowongan diberikan dari prefiks ini — maksimal satu IPv4 dan satu IPv6." + }, + "tunDesc": "Jenis adaptor", + "tunDescTooltip": "Khusus Windows: jenis tunnel untuk adaptor Wintun baru. Kosong berarti Wintun.", + "autoSystemDnsToGateway": "DNS sistem ke gateway", + "autoSystemDnsToGatewayTooltip": "Khusus Linux: mengarahkan systemd-resolved ke gateway TUN agar lookup sistem lewat Xray. Butuh gateway, dan TUN tidak berjalan jika ini gagal.", + "autoSystemWfpBlockLeak": "Blokir kebocoran (WFP)", + "autoSystemWfpBlockLeakTooltip": "Khusus Windows, bersama Auto system routes: dns menjaga DNS tetap di dalam TUN (butuh server DNS), misconfigtun memblokir versi IP yang tidak dikirim rute mana pun ke TUN." }, "info": { "mode": "Mode", @@ -789,7 +823,7 @@ "limitIpFail2banWindows": "Fail2ban tidak tersedia di Windows, sehingga batas IP tidak dapat diterapkan.", "limitIpDisabled": "Fitur batas IP dinonaktifkan di server ini.", "password": "Kata sandi", - "passwordDesc": "Hanya digunakan oleh klien Trojan dan Shadowsocks; diabaikan untuk VLESS, VMess, Hysteria, dan WireGuard.", + "passwordDesc": "Digunakan oleh klien Trojan, Shadowsocks, TUIC, dan MASQUE; diabaikan untuk VLESS, VMess, Hysteria, dan WireGuard.", "subId": "ID Langganan", "subIdDesc": "Juga berfungsi sebagai kode undangan bot Telegram untuk klien ini: siapa pun yang mengirimkannya ke bot akan ditautkan ke klien ini. Buat panjang dan acak — ID yang pendek atau mudah ditebak bisa diklaim orang lain.", "online": "Online", @@ -1932,6 +1966,7 @@ "serverNamePlaceholder": "nama server", "verifyPeerName": "Verifikasi nama peer", "pinnedSha256": "SHA256 pinned", + "useSystemCA": "Gunakan CA sistem", "shortId": "Short ID", "sockopts": "Sockopts", "keepAliveInterval": "Interval keep alive", @@ -1939,7 +1974,10 @@ "interface": "Interface", "proxyProtocol": "Proxy protocol", "tcpUserTimeoutMs": "TCP user timeout (ms)", - "tcpKeepAliveIdleS": "TCP keep-alive idle (d)" + "tcpKeepAliveIdleS": "TCP keep-alive idle (d)", + "masqueWarpDesc": "Terhubung ke Cloudflare WARP melalui MASQUE dengan kunci ECDSA P-256 yang terdaftar; host dan path default ke endpoint Cloudflare.", + "masqueWarpPublicKey": "Kunci publik endpoint", + "masqueWarpAddress": "Alamat terowongan" }, "outbound": { "tag": "Tag", @@ -2200,7 +2238,9 @@ "warpPlusData": "Data WARP+", "quota": "Kuota", "usage": "Penggunaan", - "addOutbound": "Tambah outbound" + "addOutbound": "Tambah outbound", + "masqueDesc": "Mendaftarkan perangkat WARP terpisah untuk MASQUE; pendaftaran WireGuard di atas tidak terpengaruh.", + "addMasqueOutbound": "Tambah outbound WARP melalui MASQUE" }, "dns": { "enable": "Aktifkan DNS", @@ -2244,7 +2284,11 @@ "clearAll": "Hapus Semua", "clearAllTitle": "Hapus semua server DNS?", "clearAllConfirm": "Ini akan menghapus semua server DNS dari daftar. Tidak dapat dibatalkan.", - "dnsLeakWarning": "DNS dapat bocor lewat localhost, UDP/TCP polos, DoH/DoQ mode lokal, kueri fallback, atau EDNS client IP. Gunakan DoH yang dirutekan, pin resolver di hosts, dan nonaktifkan fallback saat privasi penting." + "dnsLeakWarning": "DNS dapat bocor lewat localhost, UDP/TCP polos, DoH/DoQ mode lokal, kueri fallback, atau EDNS client IP. Gunakan DoH yang dirutekan, pin resolver di hosts, dan nonaktifkan fallback saat privasi penting.", + "script": "Skrip Lua", + "scriptDesc": "Path ke file .lua yang mendefinisikan HandleDNSQuery, absolut atau relatif terhadap folder bin Xray. Skrip lalu menjawab setiap kueri dan hanya menjangkau server melalui kodenya sendiri. Xray tidak akan berjalan jika file tidak ada atau tidak valid.", + "serverId": "ID", + "serverIdDesc": "Nama yang dipakai skrip DNS Lua untuk memilih server ini" }, "fakedns": { "add": "Tambahkan DNS Palsu", @@ -2252,7 +2296,10 @@ "poolSize": "Ukuran Kolam" }, "defaultOutbound": "Outbound default", - "defaultOutboundDesc": "Lalu lintas tanpa aturan routing memakai outbound ini (yang pertama dalam daftar)." + "defaultOutboundDesc": "Lalu lintas tanpa aturan routing memakai outbound ini (yang pertama dalam daftar).", + "routingScript": "Skrip Routing", + "routingScriptDesc": "Path ke file .lua yang mendefinisikan HandleRoute, absolut atau relatif terhadap folder bin Xray. Skrip lalu memilih setiap rute dan Xray mengabaikan semua aturan routing, termasuk aturan api panel: arahkan inbound api ke outbound api atau statistik trafik dan perubahan klien langsung akan berhenti. Xray tidak akan berjalan jika file tidak ada atau tidak valid.", + "routingScriptActive": "Skrip routing diatur di {section}, jadi Xray mengabaikan aturan ini." }, "hosts": { "addHost": "Tambah Host", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index a60fd13fe..57ef35b10 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -694,7 +694,41 @@ "listen": "インバウンドのリッスンアドレス", "custom": "カスタム" }, - "autoFill": "自動入力" + "autoFill": "自動入力", + "xdrive": { + "service": "ストレージサービス", + "serviceLocal": "ローカルフォルダ", + "serviceTemplate": "カスタムテンプレート", + "remoteFolder": "リモートフォルダ", + "clientId": "クライアント ID", + "clientSecret": "クライアントシークレット", + "refreshToken": "リフレッシュトークン", + "secrets": "シークレット", + "secretsDesc": "テンプレートで {secret0}、{secret1}、… として参照される値", + "template": "ストレージテンプレート", + "templateInvalid": "JSON オブジェクトである必要があります", + "front": "フロンティングアドレス", + "frontDesc": "ストレージ API への接続に使う任意のホストまたは IP(ドメインフロンティング)。", + "frontPort": "フロンティングポート", + "segmentBytes": "セグメントサイズ(バイト)", + "flushInterval": "送出間隔(ms)", + "pollInterval": "ポーリング間隔(ms)", + "maxPollInterval": "最大ポーリング間隔(ms)", + "eagerWindow": "先行送信ウィンドウ(ms)", + "holeTimeout": "欠落セグメントのタイムアウト(ms)", + "sessionTtl": "セッション TTL(秒)", + "concurrency": "同時実行数" + }, + "masque": { + "addressPool": "アドレスプール", + "addressPoolDesc": "トンネルのアドレスはこれらのプレフィックスから割り当てられます(IPv4 と IPv6 をそれぞれ最大 1 つ)。" + }, + "tunDesc": "アダプターの種類", + "tunDescTooltip": "Windows のみ:新しく作成する Wintun アダプターのトンネル種別。空欄の場合は Wintun。", + "autoSystemDnsToGateway": "システム DNS をゲートウェイへ", + "autoSystemDnsToGatewayTooltip": "Linux のみ:systemd-resolved を TUN のゲートウェイに向け、システムの名前解決を Xray 経由にします。ゲートウェイが必要で、失敗すると TUN は起動しません。", + "autoSystemWfpBlockLeak": "リーク遮断 (WFP)", + "autoSystemWfpBlockLeakTooltip": "Windows のみ(自動システムルートと併用):dns は DNS を TUN 内に留め(DNS サーバーが必要)、misconfigtun は TUN へのルートがない IP バージョンを遮断します。" }, "info": { "mode": "モード", @@ -789,7 +823,7 @@ "limitIpFail2banWindows": "Windows では Fail2ban を利用できないため、IP 制限を適用できません。", "limitIpDisabled": "このサーバーでは IP 制限機能が無効になっています。", "password": "パスワード", - "passwordDesc": "Trojan と Shadowsocks のクライアントのみが使用します。VLESS、VMess、Hysteria、WireGuard では無視されます。", + "passwordDesc": "Trojan、Shadowsocks、TUIC、MASQUE のクライアントが使用します。VLESS、VMess、Hysteria、WireGuard では無視されます。", "subId": "サブスクリプション ID", "subIdDesc": "このクライアントの Telegram ボット招待コードとしても使われます。ボットに送信した人がこのクライアントに紐付けられます。長くランダムな値にしてください。短い、または推測しやすい ID は他人に取得されるおそれがあります。", "online": "オンライン", @@ -1932,6 +1966,7 @@ "serverNamePlaceholder": "サーバー名", "verifyPeerName": "peer 名を検証", "pinnedSha256": "Pinned SHA256", + "useSystemCA": "システムの CA を使用", "shortId": "Short ID", "sockopts": "Sockopts", "keepAliveInterval": "keep alive 間隔", @@ -1939,7 +1974,10 @@ "interface": "インターフェース", "proxyProtocol": "Proxy protocol", "tcpUserTimeoutMs": "TCP user timeout (ms)", - "tcpKeepAliveIdleS": "TCP keep-alive idle (秒)" + "tcpKeepAliveIdleS": "TCP keep-alive idle (秒)", + "masqueWarpDesc": "登録済みの ECDSA P-256 鍵で MASQUE 経由で Cloudflare WARP に接続します。host と path の既定値は Cloudflare のエンドポイントです。", + "masqueWarpPublicKey": "エンドポイント公開鍵", + "masqueWarpAddress": "トンネルアドレス" }, "outbound": { "tag": "タグ", @@ -2200,7 +2238,9 @@ "warpPlusData": "WARP+ データ", "quota": "クォータ", "usage": "使用量", - "addOutbound": "アウトバウンドを追加" + "addOutbound": "アウトバウンドを追加", + "masqueDesc": "MASQUE 用に別の WARP デバイスを登録します。上の WireGuard の登録には影響しません。", + "addMasqueOutbound": "WARP over MASQUE のアウトバウンドを追加" }, "dns": { "enable": "DNSを有効にする", @@ -2244,7 +2284,11 @@ "clearAll": "すべて削除", "clearAllTitle": "すべての DNS サーバを削除しますか?", "clearAllConfirm": "リストからすべての DNS サーバが削除されます。この操作は元に戻せません。", - "dnsLeakWarning": "DNS は localhost、暗号化なしの UDP/TCP、ローカルモードの DoH/DoQ、フォールバック問い合わせ、EDNS client IP から漏れる可能性があります。プライバシー重視ではルーティングされた DoH、hosts 固定、フォールバック無効化を使ってください。" + "dnsLeakWarning": "DNS は localhost、暗号化なしの UDP/TCP、ローカルモードの DoH/DoQ、フォールバック問い合わせ、EDNS client IP から漏れる可能性があります。プライバシー重視ではルーティングされた DoH、hosts 固定、フォールバック無効化を使ってください。", + "script": "Lua スクリプト", + "scriptDesc": "HandleDNSQuery を定義した .lua ファイルのパス(絶対パス、または Xray の bin フォルダからの相対パス)。設定するとスクリプトがすべてのクエリに応答し、サーバーへはスクリプト自身のコードからのみ問い合わせます。ファイルが存在しないか不正な場合、Xray は起動しません。", + "serverId": "ID", + "serverIdDesc": "Lua DNS スクリプトがこのサーバーを選ぶときの名前" }, "fakedns": { "add": "フェイクDNS追加", @@ -2252,7 +2296,10 @@ "poolSize": "プールサイズ" }, "defaultOutbound": "デフォルトアウトバウンド", - "defaultOutboundDesc": "ルーティング規則に一致しないトラフィックはこのアウトバウンドを使います(一覧の先頭)。" + "defaultOutboundDesc": "ルーティング規則に一致しないトラフィックはこのアウトバウンドを使います(一覧の先頭)。", + "routingScript": "ルーティングスクリプト", + "routingScriptDesc": "HandleRoute を定義した .lua ファイルのパス(絶対パス、または Xray の bin フォルダからの相対パス)。設定するとスクリプトがすべての経路を選び、Xray はパネルの api ルールを含むすべてのルーティングルールを無視します。api インバウンドを api アウトバウンドへ送らないと、トラフィック統計とクライアント変更の即時反映が止まります。ファイルが存在しないか不正な場合、Xray は起動しません。", + "routingScriptActive": "{section} でルーティングスクリプトが設定されているため、Xray はこれらのルールを無視します。" }, "hosts": { "addHost": "ホストを追加", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index bece25ab0..44adc9319 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -694,7 +694,41 @@ "listen": "Endereço de escuta do inbound", "custom": "Personalizada" }, - "autoFill": "Preenchimento automático" + "autoFill": "Preenchimento automático", + "xdrive": { + "service": "Serviço de armazenamento", + "serviceLocal": "Pasta local", + "serviceTemplate": "Template personalizado", + "remoteFolder": "Pasta remota", + "clientId": "Client ID", + "clientSecret": "Client Secret", + "refreshToken": "Refresh Token", + "secrets": "Segredos", + "secretsDesc": "Valores que o template lê como {secret0}, {secret1}, …", + "template": "Template de armazenamento", + "templateInvalid": "Deve ser um objeto JSON", + "front": "Endereço de fronting", + "frontDesc": "Host ou IP opcional usado para conectar à API de armazenamento (domain fronting).", + "frontPort": "Porta de fronting", + "segmentBytes": "Tamanho do segmento (bytes)", + "flushInterval": "Intervalo de envio (ms)", + "pollInterval": "Intervalo de polling (ms)", + "maxPollInterval": "Intervalo máximo de polling (ms)", + "eagerWindow": "Janela antecipada (ms)", + "holeTimeout": "Timeout de segmento ausente (ms)", + "sessionTtl": "TTL da sessão (s)", + "concurrency": "Concorrência" + }, + "masque": { + "addressPool": "Pool de endereços", + "addressPoolDesc": "Os endereços do túnel são atribuídos a partir destes prefixos — no máximo um IPv4 e um IPv6." + }, + "tunDesc": "Tipo de adaptador", + "tunDescTooltip": "Somente Windows: o tipo de túnel com que um novo adaptador Wintun é criado. Vazio significa Wintun.", + "autoSystemDnsToGateway": "DNS do sistema para o gateway", + "autoSystemDnsToGatewayTooltip": "Somente Linux: aponta o systemd-resolved para o gateway do TUN para que as consultas do sistema passem pelo Xray. Precisa de um gateway, e o TUN não inicia se isso falhar.", + "autoSystemWfpBlockLeak": "Bloquear vazamentos (WFP)", + "autoSystemWfpBlockLeakTooltip": "Somente Windows, com Auto system routes: dns mantém o DNS dentro do TUN (precisa de servidores DNS) e misconfigtun bloqueia uma versão de IP que nenhuma rota envia ao TUN." }, "info": { "mode": "Modo", @@ -789,7 +823,7 @@ "limitIpFail2banWindows": "O Fail2ban não está disponível no Windows, portanto o limite de IP não pode ser aplicado.", "limitIpDisabled": "O recurso de limite de IP está desativado neste servidor.", "password": "Senha", - "passwordDesc": "Usada apenas pelos clientes Trojan e Shadowsocks; ignorada para VLESS, VMess, Hysteria e WireGuard.", + "passwordDesc": "Usada pelos clientes Trojan, Shadowsocks, TUIC e MASQUE; ignorada para VLESS, VMess, Hysteria e WireGuard.", "subId": "ID da assinatura", "subIdDesc": "Também funciona como código de convite deste cliente no bot do Telegram: quem enviá-lo ao bot fica vinculado a este cliente. Mantenha-o longo e aleatório — um ID curto ou fácil de adivinhar pode ser reivindicado por outra pessoa.", "online": "Online", @@ -1932,6 +1966,7 @@ "serverNamePlaceholder": "nome do servidor", "verifyPeerName": "Verificar nome do peer", "pinnedSha256": "SHA256 pinned", + "useSystemCA": "Usar CA do sistema", "shortId": "Short ID", "sockopts": "Sockopts", "keepAliveInterval": "Intervalo keep alive", @@ -1939,7 +1974,10 @@ "interface": "Interface", "proxyProtocol": "Proxy protocol", "tcpUserTimeoutMs": "TCP user timeout (ms)", - "tcpKeepAliveIdleS": "TCP keep-alive idle (s)" + "tcpKeepAliveIdleS": "TCP keep-alive idle (s)", + "masqueWarpDesc": "Conecta ao Cloudflare WARP via MASQUE com uma chave ECDSA P-256 registrada; host e path usam por padrão o endpoint da Cloudflare.", + "masqueWarpPublicKey": "Chave pública do endpoint", + "masqueWarpAddress": "Endereços do túnel" }, "outbound": { "tag": "Tag", @@ -2200,7 +2238,9 @@ "warpPlusData": "Dados WARP+", "quota": "Quota", "usage": "Uso", - "addOutbound": "Adicionar saída" + "addOutbound": "Adicionar saída", + "masqueDesc": "Registra um dispositivo WARP separado inscrito para MASQUE; o registro WireGuard acima não é afetado.", + "addMasqueOutbound": "Adicionar outbound WARP via MASQUE" }, "dns": { "enable": "Ativar DNS", @@ -2244,7 +2284,11 @@ "clearAll": "Remover Todos", "clearAllTitle": "Remover todos os servidores DNS?", "clearAllConfirm": "Isso remove todos os servidores DNS da lista. Não pode ser desfeito.", - "dnsLeakWarning": "DNS pode vazar por localhost, UDP/TCP sem criptografia, DoH/DoQ em modo local, consultas de fallback ou EDNS client IP. Use DoH roteado, fixe resolvedores em hosts e desative fallback quando privacidade importar." + "dnsLeakWarning": "DNS pode vazar por localhost, UDP/TCP sem criptografia, DoH/DoQ em modo local, consultas de fallback ou EDNS client IP. Use DoH roteado, fixe resolvedores em hosts e desative fallback quando privacidade importar.", + "script": "Script Lua", + "scriptDesc": "Caminho para um arquivo .lua que define HandleDNSQuery, absoluto ou relativo à pasta bin do Xray. O script passa a responder a todas as consultas e só alcança os servidores pelo próprio código. O Xray não inicia se o arquivo estiver ausente ou for inválido.", + "serverId": "ID", + "serverIdDesc": "Nome com que um script DNS em Lua escolhe este servidor" }, "fakedns": { "add": "Adicionar Fake DNS", @@ -2252,7 +2296,10 @@ "poolSize": "Tamanho do Pool" }, "defaultOutbound": "Saída padrão", - "defaultOutboundDesc": "Tráfego sem regra de roteamento usa esta saída (a primeira da lista)." + "defaultOutboundDesc": "Tráfego sem regra de roteamento usa esta saída (a primeira da lista).", + "routingScript": "Script de roteamento", + "routingScriptDesc": "Caminho para um arquivo .lua que define HandleRoute, absoluto ou relativo à pasta bin do Xray. O script passa a escolher cada rota e o Xray ignora todas as regras de roteamento, inclusive a regra api do painel: envie o inbound api para o outbound api ou as estatísticas de tráfego e as alterações de clientes ao vivo param. O Xray não inicia se o arquivo estiver ausente ou for inválido.", + "routingScriptActive": "Há um script de roteamento definido em {section}, então o Xray ignora estas regras." }, "hosts": { "addHost": "Adicionar Host", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index 2211e2fb0..5d3ec7663 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -695,7 +695,41 @@ "node": "Адрес узла", "listen": "Адрес прослушивания inbound", "custom": "Пользовательская" - } + }, + "xdrive": { + "service": "Сервис хранилища", + "serviceLocal": "Локальная папка", + "serviceTemplate": "Свой шаблон", + "remoteFolder": "Удалённая папка", + "clientId": "Client ID", + "clientSecret": "Client Secret", + "refreshToken": "Refresh Token", + "secrets": "Секреты", + "secretsDesc": "Значения, которые шаблон читает как {secret0}, {secret1}, …", + "template": "Шаблон хранилища", + "templateInvalid": "Должен быть объект JSON", + "front": "Адрес фронтинга", + "frontDesc": "Необязательный хост или IP для подключения к API хранилища (domain fronting).", + "frontPort": "Порт фронтинга", + "segmentBytes": "Размер сегмента (байт)", + "flushInterval": "Интервал отправки (мс)", + "pollInterval": "Интервал опроса (мс)", + "maxPollInterval": "Макс. интервал опроса (мс)", + "eagerWindow": "Окно упреждения (мс)", + "holeTimeout": "Тайм-аут пропущенного сегмента (мс)", + "sessionTtl": "TTL сессии (с)", + "concurrency": "Параллелизм" + }, + "masque": { + "addressPool": "Пул адресов", + "addressPoolDesc": "Адреса туннелей выдаются из этих префиксов — не более одного IPv4 и одного IPv6." + }, + "tunDesc": "Тип адаптера", + "tunDescTooltip": "Только Windows: тип туннеля, с которым создаётся новый адаптер Wintun. Пусто — Wintun.", + "autoSystemDnsToGateway": "Системный DNS на шлюз", + "autoSystemDnsToGatewayTooltip": "Только Linux: направляет systemd-resolved на шлюз TUN, чтобы системные запросы шли через Xray. Нужен шлюз; при ошибке TUN не запустится.", + "autoSystemWfpBlockLeak": "Блокировка утечек (WFP)", + "autoSystemWfpBlockLeakTooltip": "Только Windows, вместе с авто-маршрутами системы: dns удерживает DNS внутри TUN (нужны DNS-серверы), misconfigtun блокирует версию IP, которую ни один маршрут не ведёт в TUN." }, "info": { "mode": "Режим", @@ -789,7 +823,7 @@ "limitIpFail2banWindows": "Fail2ban недоступен в Windows, поэтому ограничение по IP не может быть применено.", "limitIpDisabled": "Функция ограничения по IP отключена на этом сервере.", "password": "Пароль", - "passwordDesc": "Используется клиентами Trojan, Shadowsocks и TUIC; игнорируется для VLESS, VMess, Hysteria и WireGuard.", + "passwordDesc": "Используется клиентами Trojan, Shadowsocks, TUIC и MASQUE; игнорируется для VLESS, VMess, Hysteria и WireGuard.", "subId": "ID подписки", "subIdDesc": "Также служит кодом приглашения этого клиента в Telegram-боте: тот, кто отправит его боту, будет привязан к этому клиенту. Используйте длинное случайное значение — короткий или легко угадываемый ID может присвоить кто-то другой.", "online": "В сети", @@ -1932,6 +1966,7 @@ "serverNamePlaceholder": "имя сервера", "verifyPeerName": "Проверять имя peer", "pinnedSha256": "Pinned SHA256", + "useSystemCA": "Использовать системные CA", "shortId": "Short ID", "sockopts": "Sockopts", "keepAliveInterval": "Интервал keep alive", @@ -1939,7 +1974,10 @@ "interface": "Интерфейс", "proxyProtocol": "Proxy protocol", "tcpUserTimeoutMs": "TCP user timeout (мс)", - "tcpKeepAliveIdleS": "TCP keep-alive idle (с)" + "tcpKeepAliveIdleS": "TCP keep-alive idle (с)", + "masqueWarpDesc": "Подключение к Cloudflare WARP через MASQUE с зарегистрированным ключом ECDSA P-256; host и path по умолчанию указывают на конечную точку Cloudflare.", + "masqueWarpPublicKey": "Открытый ключ конечной точки", + "masqueWarpAddress": "Адреса туннеля" }, "outbound": { "tag": "Тег", @@ -2200,7 +2238,9 @@ "warpPlusData": "WARP+ data", "quota": "Квота", "usage": "Использование", - "addOutbound": "Добавить исходящий" + "addOutbound": "Добавить исходящий", + "masqueDesc": "Регистрирует отдельное устройство WARP для MASQUE; регистрация WireGuard выше не затрагивается.", + "addMasqueOutbound": "Добавить аутбаунд WARP через MASQUE" }, "dns": { "enable": "Включить DNS", @@ -2244,7 +2284,11 @@ "clearAll": "Удалить все", "clearAllTitle": "Удалить все DNS-серверы?", "clearAllConfirm": "Все DNS-серверы будут удалены из списка. Это действие нельзя отменить.", - "dnsLeakWarning": "DNS может утекать через localhost, обычный UDP/TCP, локальный режим DoH/DoQ, fallback-запросы или EDNS client IP. Для приватности используйте маршрутизируемый DoH, закрепляйте резолверы в hosts и отключайте fallback." + "dnsLeakWarning": "DNS может утекать через localhost, обычный UDP/TCP, локальный режим DoH/DoQ, fallback-запросы или EDNS client IP. Для приватности используйте маршрутизируемый DoH, закрепляйте резолверы в hosts и отключайте fallback.", + "script": "Lua-скрипт", + "scriptDesc": "Путь к файлу .lua с функцией HandleDNSQuery — абсолютный или относительно папки bin Xray. Скрипт отвечает на все запросы и обращается к серверам только из своего кода. Xray не запустится, если файл отсутствует или содержит ошибки.", + "serverId": "ID", + "serverIdDesc": "Имя, по которому DNS-скрипт на Lua выбирает этот сервер" }, "fakedns": { "add": "Создать Fake DNS", @@ -2252,7 +2296,10 @@ "poolSize": "Размер пула" }, "defaultOutbound": "Исходящий по умолчанию", - "defaultOutboundDesc": "Трафик без совпадения с правилами маршрутизации идёт через этот исходящий (первый в списке)." + "defaultOutboundDesc": "Трафик без совпадения с правилами маршрутизации идёт через этот исходящий (первый в списке).", + "routingScript": "Скрипт маршрутизации", + "routingScriptDesc": "Путь к файлу .lua с функцией HandleRoute — абсолютный или относительно папки bin Xray. Скрипт выбирает каждый маршрут, а Xray игнорирует все правила маршрутизации, включая правило api панели: направляйте входящий api на исходящий api, иначе остановятся статистика трафика и применение изменений клиентов на лету. Xray не запустится, если файл отсутствует или содержит ошибки.", + "routingScriptActive": "В разделе «{section}» задан скрипт маршрутизации, поэтому Xray игнорирует эти правила." }, "hosts": { "addHost": "Добавить хост", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index bce372c21..1ad9d1dca 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -694,7 +694,41 @@ "bytesPerSecTip": "Eşik aşıldıktan sonra fallback trafiğine uygulanan hız sınırı (bayt/sn); böylece sondalar sunucunuzu hedefe ücretsiz bant genişliği olarak kullanamaz. 0 = sınır yok (bu yönü devre dışı bırakır).", "burstBytesPerSec": "Saniye Başına Patlama Baytı", "burstBytesPerSecTip": "Sabit hızın üzerindeki kısa patlamalar için pay (token-bucket boyutu). Saniye Başına Bayt değerinden düşükse ona eşitlenecek şekilde yükseltilir.", - "autoFill": "Otomatik Doldur" + "autoFill": "Otomatik Doldur", + "xdrive": { + "service": "Depolama hizmeti", + "serviceLocal": "Yerel klasör", + "serviceTemplate": "Özel şablon", + "remoteFolder": "Uzak klasör", + "clientId": "Client ID", + "clientSecret": "Client Secret", + "refreshToken": "Refresh Token", + "secrets": "Gizli değerler", + "secretsDesc": "Şablonun {secret0}, {secret1}, … olarak okuduğu değerler", + "template": "Depolama şablonu", + "templateInvalid": "Bir JSON nesnesi olmalı", + "front": "Fronting adresi", + "frontDesc": "Depolama API'sine bağlanmak için isteğe bağlı ana makine veya IP (domain fronting).", + "frontPort": "Fronting portu", + "segmentBytes": "Segment boyutu (bayt)", + "flushInterval": "Gönderim aralığı (ms)", + "pollInterval": "Yoklama aralığı (ms)", + "maxPollInterval": "Maks. yoklama aralığı (ms)", + "eagerWindow": "Erken gönderim penceresi (ms)", + "holeTimeout": "Eksik segment zaman aşımı (ms)", + "sessionTtl": "Oturum TTL (s)", + "concurrency": "Eşzamanlılık" + }, + "masque": { + "addressPool": "Adres havuzu", + "addressPoolDesc": "Tünel adresleri bu öneklerden atanır — en fazla bir IPv4 ve bir IPv6." + }, + "tunDesc": "Bağdaştırıcı türü", + "tunDescTooltip": "Yalnızca Windows: yeni bir Wintun bağdaştırıcısının oluşturulduğu tünel türü. Boş bırakılırsa Wintun.", + "autoSystemDnsToGateway": "Sistem DNS'ini ağ geçidine yönlendir", + "autoSystemDnsToGatewayTooltip": "Yalnızca Linux: sistem sorguları Xray üzerinden gitsin diye systemd-resolved'ı TUN ağ geçidine yönlendirir. Ağ geçidi gerekir ve bu başarısız olursa TUN başlamaz.", + "autoSystemWfpBlockLeak": "Sızıntıları engelle (WFP)", + "autoSystemWfpBlockLeakTooltip": "Yalnızca Windows, Auto system routes ile: dns, DNS'i TUN içinde tutar (DNS sunucuları gerekir); misconfigtun, hiçbir rotanın TUN'a göndermediği IP sürümünü engeller." }, "info": { "mode": "Mod", @@ -789,7 +823,7 @@ "limitIpFail2banWindows": "Fail2ban Windows'ta kullanılamadığından IP sınırı uygulanamaz.", "limitIpDisabled": "IP sınırı özelliği bu sunucuda devre dışı.", "password": "Şifre", - "passwordDesc": "Yalnızca Trojan ve Shadowsocks istemcileri tarafından kullanılır; VLESS, VMess, Hysteria ve WireGuard için yok sayılır.", + "passwordDesc": "Trojan, Shadowsocks, TUIC ve MASQUE istemcileri tarafından kullanılır; VLESS, VMess, Hysteria ve WireGuard için yok sayılır.", "subId": "Abonelik ID'si", "subIdDesc": "Bu istemcinin Telegram botu davet kodu olarak da kullanılır: bunu bota gönderen kişi bu istemciye bağlanır. Uzun ve rastgele tutun — kısa veya tahmin edilebilir bir kimlik başkası tarafından sahiplenilebilir.", "online": "Çevrimiçi", @@ -1932,6 +1966,7 @@ "serverNamePlaceholder": "sunucu adı", "verifyPeerName": "Peer Adını Doğrula", "pinnedSha256": "Sabitlenmiş SHA256", + "useSystemCA": "Sistem CA'sını kullan", "shortId": "Kısa Kimlik", "sockopts": "Sockopts", "keepAliveInterval": "Keep Alive Aralığı", @@ -1939,7 +1974,10 @@ "interface": "Arabirim", "proxyProtocol": "Proxy Protocol", "tcpUserTimeoutMs": "TCP User Timeout (ms)", - "tcpKeepAliveIdleS": "TCP keep-alive bekleme süresi (saniye)" + "tcpKeepAliveIdleS": "TCP keep-alive bekleme süresi (saniye)", + "masqueWarpDesc": "Kayıtlı bir ECDSA P-256 anahtarıyla MASQUE üzerinden Cloudflare WARP'a bağlanır; host ve path varsayılan olarak Cloudflare uç noktasıdır.", + "masqueWarpPublicKey": "Uç nokta açık anahtarı", + "masqueWarpAddress": "Tünel adresleri" }, "outbound": { "tag": "Etiket", @@ -2200,7 +2238,9 @@ "warpPlusData": "WARP+ Veri", "quota": "Kota", "usage": "Kullanım", - "addOutbound": "Giden Bağlantı Ekle" + "addOutbound": "Giden Bağlantı Ekle", + "masqueDesc": "MASQUE için ayrı bir WARP cihazı kaydeder; yukarıdaki WireGuard kaydı etkilenmez.", + "addMasqueOutbound": "MASQUE üzerinden WARP outbound ekle" }, "dns": { "enable": "DNS'yi Etkinleştir", @@ -2244,7 +2284,11 @@ "clearAll": "Tümünü Sil", "clearAllTitle": "Tüm DNS sunucularını sil?", "clearAllConfirm": "Bu işlem tüm DNS sunucularını listeden kaldırır. Geri alınamaz.", - "dnsLeakWarning": "DNS; localhost, düz UDP/TCP, yerel mod DoH/DoQ, fallback sorguları veya EDNS client IP üzerinden sızabilir. Gizlilik önemliyse yönlendirilmiş DoH, hosts sabitlemeleri ve kapalı fallback kullanın." + "dnsLeakWarning": "DNS; localhost, düz UDP/TCP, yerel mod DoH/DoQ, fallback sorguları veya EDNS client IP üzerinden sızabilir. Gizlilik önemliyse yönlendirilmiş DoH, hosts sabitlemeleri ve kapalı fallback kullanın.", + "script": "Lua Betiği", + "scriptDesc": "HandleDNSQuery tanımlayan bir .lua dosyasının yolu; mutlak ya da Xray bin klasörüne göre göreli. Betik bundan sonra her sorguyu yanıtlar ve sunuculara yalnızca kendi koduyla ulaşır. Dosya yoksa veya geçersizse Xray başlamaz.", + "serverId": "Kimlik", + "serverIdDesc": "Bir Lua DNS betiğinin bu sunucuyu seçerken kullandığı ad" }, "fakedns": { "add": "Sahte DNS Ekle", @@ -2252,7 +2296,10 @@ "poolSize": "Havuz Boyutu" }, "defaultOutbound": "Varsayılan giden", - "defaultOutboundDesc": "Yönlendirme kuralıyla eşleşmeyen trafik bu gideni kullanır (listedeki ilk giden)." + "defaultOutboundDesc": "Yönlendirme kuralıyla eşleşmeyen trafik bu gideni kullanır (listedeki ilk giden).", + "routingScript": "Yönlendirme Betiği", + "routingScriptDesc": "HandleRoute tanımlayan bir .lua dosyasının yolu; mutlak ya da Xray bin klasörüne göre göreli. Betik bundan sonra her rotayı seçer ve Xray, panelin api kuralı dahil tüm yönlendirme kurallarını yok sayar: api gelen bağlantısını api giden bağlantısına yönlendirin, yoksa trafik istatistikleri ve canlı istemci değişiklikleri durur. Dosya yoksa veya geçersizse Xray başlamaz.", + "routingScriptActive": "{section} bölümünde bir yönlendirme betiği ayarlı, bu yüzden Xray bu kuralları yok sayar." }, "hosts": { "addHost": "Host Ekle", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index f72c10c53..244858a63 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -694,7 +694,41 @@ "listen": "Адреса прослуховування inbound", "custom": "Користувацька" }, - "autoFill": "Автозаповнення" + "autoFill": "Автозаповнення", + "xdrive": { + "service": "Сервіс сховища", + "serviceLocal": "Локальна тека", + "serviceTemplate": "Власний шаблон", + "remoteFolder": "Віддалена тека", + "clientId": "Client ID", + "clientSecret": "Client Secret", + "refreshToken": "Refresh Token", + "secrets": "Секрети", + "secretsDesc": "Значення, які шаблон читає як {secret0}, {secret1}, …", + "template": "Шаблон сховища", + "templateInvalid": "Має бути об'єкт JSON", + "front": "Адреса фронтингу", + "frontDesc": "Необов'язковий хост або IP для підключення до API сховища (domain fronting).", + "frontPort": "Порт фронтингу", + "segmentBytes": "Розмір сегмента (байт)", + "flushInterval": "Інтервал надсилання (мс)", + "pollInterval": "Інтервал опитування (мс)", + "maxPollInterval": "Макс. інтервал опитування (мс)", + "eagerWindow": "Вікно випередження (мс)", + "holeTimeout": "Тайм-аут пропущеного сегмента (мс)", + "sessionTtl": "TTL сесії (с)", + "concurrency": "Паралельність" + }, + "masque": { + "addressPool": "Пул адрес", + "addressPoolDesc": "Адреси тунелів видаються з цих префіксів — не більше одного IPv4 та одного IPv6." + }, + "tunDesc": "Тип адаптера", + "tunDescTooltip": "Лише Windows: тип тунелю, з яким створюється новий адаптер Wintun. Порожньо — Wintun.", + "autoSystemDnsToGateway": "Системний DNS на шлюз", + "autoSystemDnsToGatewayTooltip": "Лише Linux: спрямовує systemd-resolved на шлюз TUN, щоб системні запити йшли через Xray. Потрібен шлюз; у разі помилки TUN не запуститься.", + "autoSystemWfpBlockLeak": "Блокування витоків (WFP)", + "autoSystemWfpBlockLeakTooltip": "Лише Windows, разом з авто-маршрутами системи: dns утримує DNS усередині TUN (потрібні DNS-сервери), misconfigtun блокує версію IP, яку жоден маршрут не веде в TUN." }, "info": { "mode": "Режим", @@ -789,7 +823,7 @@ "limitIpFail2banWindows": "Fail2ban недоступний у Windows, тому обмеження за IP не може бути застосоване.", "limitIpDisabled": "Функцію обмеження за IP вимкнено на цьому сервері.", "password": "Пароль", - "passwordDesc": "Використовується лише клієнтами Trojan і Shadowsocks; ігнорується для VLESS, VMess, Hysteria та WireGuard.", + "passwordDesc": "Використовується клієнтами Trojan, Shadowsocks, TUIC і MASQUE; ігнорується для VLESS, VMess, Hysteria та WireGuard.", "subId": "ID підписки", "subIdDesc": "Також слугує кодом запрошення цього клієнта в Telegram-боті: той, хто надішле його боту, буде прив'язаний до цього клієнта. Використовуйте довге випадкове значення — короткий або легко вгадуваний ID може привласнити хтось інший.", "online": "У мережі", @@ -1932,6 +1966,7 @@ "serverNamePlaceholder": "ім'я сервера", "verifyPeerName": "Перевіряти ім'я peer", "pinnedSha256": "Pinned SHA256", + "useSystemCA": "Використовувати системні CA", "shortId": "Short ID", "sockopts": "Sockopts", "keepAliveInterval": "Інтервал keep alive", @@ -1939,7 +1974,10 @@ "interface": "Інтерфейс", "proxyProtocol": "Proxy protocol", "tcpUserTimeoutMs": "TCP user timeout (мс)", - "tcpKeepAliveIdleS": "TCP keep-alive idle (с)" + "tcpKeepAliveIdleS": "TCP keep-alive idle (с)", + "masqueWarpDesc": "Підключення до Cloudflare WARP через MASQUE із зареєстрованим ключем ECDSA P-256; host і path за замовчуванням вказують на кінцеву точку Cloudflare.", + "masqueWarpPublicKey": "Відкритий ключ кінцевої точки", + "masqueWarpAddress": "Адреси тунелю" }, "outbound": { "tag": "Тег", @@ -2200,7 +2238,9 @@ "warpPlusData": "WARP+ data", "quota": "Квота", "usage": "Використання", - "addOutbound": "Додати вихідний" + "addOutbound": "Додати вихідний", + "masqueDesc": "Реєструє окремий пристрій WARP для MASQUE; реєстрація WireGuard вище не змінюється.", + "addMasqueOutbound": "Додати аутбаунд WARP через MASQUE" }, "dns": { "enable": "Увімкнути DNS", @@ -2244,7 +2284,11 @@ "clearAll": "Видалити всі", "clearAllTitle": "Видалити всі DNS-сервери?", "clearAllConfirm": "Усі DNS-сервери буде видалено зі списку. Дію не можна скасувати.", - "dnsLeakWarning": "DNS може витікати через localhost, звичайний UDP/TCP, локальний режим DoH/DoQ, fallback-запити або EDNS client IP. Для приватності використовуйте маршрутизований DoH, фіксацію hosts і вимикайте fallback." + "dnsLeakWarning": "DNS може витікати через localhost, звичайний UDP/TCP, локальний режим DoH/DoQ, fallback-запити або EDNS client IP. Для приватності використовуйте маршрутизований DoH, фіксацію hosts і вимикайте fallback.", + "script": "Lua-скрипт", + "scriptDesc": "Шлях до файлу .lua з функцією HandleDNSQuery — абсолютний або відносно теки bin Xray. Скрипт відповідає на всі запити й звертається до серверів лише зі свого коду. Xray не запуститься, якщо файл відсутній або містить помилки.", + "serverId": "ID", + "serverIdDesc": "Ім'я, за яким DNS-скрипт на Lua вибирає цей сервер" }, "fakedns": { "add": "Додати підроблений DNS", @@ -2252,7 +2296,10 @@ "poolSize": "Розмір пулу" }, "defaultOutbound": "Вихідний за замовчуванням", - "defaultOutboundDesc": "Трафік без збігу з правилами маршрутизації йде через цей вихідний (перший у списку)." + "defaultOutboundDesc": "Трафік без збігу з правилами маршрутизації йде через цей вихідний (перший у списку).", + "routingScript": "Скрипт маршрутизації", + "routingScriptDesc": "Шлях до файлу .lua з функцією HandleRoute — абсолютний або відносно теки bin Xray. Скрипт обирає кожен маршрут, а Xray ігнорує всі правила маршрутизації, зокрема правило api панелі: спрямовуйте вхідний api на вихідний api, інакше зупиниться статистика трафіку й застосування змін клієнтів на льоту. Xray не запуститься, якщо файл відсутній або містить помилки.", + "routingScriptActive": "У розділі «{section}» задано скрипт маршрутизації, тому Xray ігнорує ці правила." }, "hosts": { "addHost": "Додати хост", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 480ce8d64..2a2219406 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -694,7 +694,41 @@ "listen": "Địa chỉ listen inbound", "custom": "Tùy chỉnh" }, - "autoFill": "Tự động điền" + "autoFill": "Tự động điền", + "xdrive": { + "service": "Dịch vụ lưu trữ", + "serviceLocal": "Thư mục cục bộ", + "serviceTemplate": "Template tùy chỉnh", + "remoteFolder": "Thư mục từ xa", + "clientId": "Client ID", + "clientSecret": "Client Secret", + "refreshToken": "Refresh Token", + "secrets": "Giá trị bí mật", + "secretsDesc": "Các giá trị template đọc dưới dạng {secret0}, {secret1}, …", + "template": "Template lưu trữ", + "templateInvalid": "Phải là một đối tượng JSON", + "front": "Địa chỉ fronting", + "frontDesc": "Host hoặc IP tùy chọn để kết nối tới API lưu trữ (domain fronting).", + "frontPort": "Cổng fronting", + "segmentBytes": "Kích thước phân đoạn (byte)", + "flushInterval": "Khoảng gửi (ms)", + "pollInterval": "Khoảng thăm dò (ms)", + "maxPollInterval": "Khoảng thăm dò tối đa (ms)", + "eagerWindow": "Cửa sổ gửi sớm (ms)", + "holeTimeout": "Thời gian chờ phân đoạn thiếu (ms)", + "sessionTtl": "TTL phiên (s)", + "concurrency": "Số luồng đồng thời" + }, + "masque": { + "addressPool": "Dải địa chỉ", + "addressPoolDesc": "Địa chỉ đường hầm được cấp từ các tiền tố này — tối đa một IPv4 và một IPv6." + }, + "tunDesc": "Loại adapter", + "tunDescTooltip": "Chỉ Windows: loại tunnel dùng khi tạo adapter Wintun mới. Để trống nghĩa là Wintun.", + "autoSystemDnsToGateway": "DNS hệ thống tới gateway", + "autoSystemDnsToGatewayTooltip": "Chỉ Linux: trỏ systemd-resolved tới gateway của TUN để truy vấn của hệ thống đi qua Xray. Cần có gateway, và TUN sẽ không khởi động nếu việc này thất bại.", + "autoSystemWfpBlockLeak": "Chặn rò rỉ (WFP)", + "autoSystemWfpBlockLeakTooltip": "Chỉ Windows, cùng với Auto system routes: dns giữ DNS bên trong TUN (cần máy chủ DNS), misconfigtun chặn phiên bản IP mà không tuyến nào đưa vào TUN." }, "info": { "mode": "Chế độ", @@ -789,7 +823,7 @@ "limitIpFail2banWindows": "Fail2ban không khả dụng trên Windows nên không thể áp dụng giới hạn IP.", "limitIpDisabled": "Tính năng giới hạn IP đã bị tắt trên máy chủ này.", "password": "Mật khẩu", - "passwordDesc": "Chỉ được dùng bởi các client Trojan và Shadowsocks; bị bỏ qua đối với VLESS, VMess, Hysteria và WireGuard.", + "passwordDesc": "Được dùng bởi các client Trojan, Shadowsocks, TUIC và MASQUE; bị bỏ qua đối với VLESS, VMess, Hysteria và WireGuard.", "subId": "ID đăng ký", "subIdDesc": "Cũng được dùng làm mã mời bot Telegram của client này: ai gửi mã này cho bot sẽ được liên kết với client. Hãy để mã dài và ngẫu nhiên — ID ngắn hoặc dễ đoán có thể bị người khác chiếm.", "online": "Trực tuyến", @@ -1932,6 +1966,7 @@ "serverNamePlaceholder": "tên máy chủ", "verifyPeerName": "Xác minh tên peer", "pinnedSha256": "SHA256 pinned", + "useSystemCA": "Dùng CA của hệ thống", "shortId": "Short ID", "sockopts": "Sockopts", "keepAliveInterval": "Khoảng keep alive", @@ -1939,7 +1974,10 @@ "interface": "Giao diện", "proxyProtocol": "Proxy protocol", "tcpUserTimeoutMs": "TCP user timeout (ms)", - "tcpKeepAliveIdleS": "TCP keep-alive idle (s)" + "tcpKeepAliveIdleS": "TCP keep-alive idle (s)", + "masqueWarpDesc": "Kết nối Cloudflare WARP qua MASQUE bằng khóa ECDSA P-256 đã đăng ký; host và path mặc định là endpoint của Cloudflare.", + "masqueWarpPublicKey": "Khóa công khai endpoint", + "masqueWarpAddress": "Địa chỉ đường hầm" }, "outbound": { "tag": "Tag", @@ -2200,7 +2238,9 @@ "warpPlusData": "Dữ liệu WARP+", "quota": "Hạn ngạch", "usage": "Sử dụng", - "addOutbound": "Thêm outbound" + "addOutbound": "Thêm outbound", + "masqueDesc": "Đăng ký một thiết bị WARP riêng cho MASQUE; đăng ký WireGuard ở trên không bị ảnh hưởng.", + "addMasqueOutbound": "Thêm outbound WARP qua MASQUE" }, "dns": { "enable": "Kích hoạt DNS", @@ -2244,7 +2284,11 @@ "clearAll": "Xóa tất cả", "clearAllTitle": "Xóa tất cả máy chủ DNS?", "clearAllConfirm": "Thao tác này sẽ xóa toàn bộ máy chủ DNS khỏi danh sách. Không thể hoàn tác.", - "dnsLeakWarning": "DNS có thể rò rỉ qua localhost, UDP/TCP không mã hóa, DoH/DoQ chế độ cục bộ, truy vấn fallback hoặc EDNS client IP. Hãy dùng DoH được định tuyến, ghim resolver trong hosts và tắt fallback khi cần riêng tư." + "dnsLeakWarning": "DNS có thể rò rỉ qua localhost, UDP/TCP không mã hóa, DoH/DoQ chế độ cục bộ, truy vấn fallback hoặc EDNS client IP. Hãy dùng DoH được định tuyến, ghim resolver trong hosts và tắt fallback khi cần riêng tư.", + "script": "Script Lua", + "scriptDesc": "Đường dẫn tới tệp .lua định nghĩa HandleDNSQuery, tuyệt đối hoặc tương đối với thư mục bin của Xray. Khi đó script trả lời mọi truy vấn và chỉ dùng các máy chủ qua mã của chính nó. Xray sẽ không khởi động nếu tệp không tồn tại hoặc không hợp lệ.", + "serverId": "ID", + "serverIdDesc": "Tên mà script DNS Lua dùng để chọn máy chủ này" }, "fakedns": { "add": "Thêm DNS giả", @@ -2252,7 +2296,10 @@ "poolSize": "Kích thước bể bơi" }, "defaultOutbound": "Outbound mặc định", - "defaultOutboundDesc": "Lưu lượng không khớp quy tắc định tuyến dùng outbound này (mục đầu danh sách)." + "defaultOutboundDesc": "Lưu lượng không khớp quy tắc định tuyến dùng outbound này (mục đầu danh sách).", + "routingScript": "Script định tuyến", + "routingScriptDesc": "Đường dẫn tới tệp .lua định nghĩa HandleRoute, tuyệt đối hoặc tương đối với thư mục bin của Xray. Khi đó script chọn mọi tuyến và Xray bỏ qua tất cả quy tắc định tuyến, kể cả quy tắc api của panel: hãy chuyển inbound api tới outbound api, nếu không thống kê lưu lượng và việc áp dụng thay đổi client trực tiếp sẽ dừng. Xray sẽ không khởi động nếu tệp không tồn tại hoặc không hợp lệ.", + "routingScriptActive": "Một script định tuyến đã được đặt trong {section}, nên Xray bỏ qua các quy tắc này." }, "hosts": { "addHost": "Thêm Host", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 8ebaf36f6..f38a1d811 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -694,7 +694,41 @@ "custom": "自定义" }, "verifyPeerCertByName": "按名称验证对端证书", - "autoFill": "自动填充" + "autoFill": "自动填充", + "xdrive": { + "service": "存储服务", + "serviceLocal": "本地文件夹", + "serviceTemplate": "自定义模板", + "remoteFolder": "远程文件夹", + "clientId": "客户端 ID", + "clientSecret": "客户端密钥", + "refreshToken": "刷新令牌", + "secrets": "密钥", + "secretsDesc": "模板中以 {secret0}、{secret1}… 引用的值", + "template": "存储模板", + "templateInvalid": "必须是 JSON 对象", + "front": "前置地址", + "frontDesc": "连接存储 API 时使用的可选主机或 IP(域前置)。", + "frontPort": "前置端口", + "segmentBytes": "分段大小(字节)", + "flushInterval": "发送间隔(毫秒)", + "pollInterval": "轮询间隔(毫秒)", + "maxPollInterval": "最大轮询间隔(毫秒)", + "eagerWindow": "预发窗口(毫秒)", + "holeTimeout": "缺失分段超时(毫秒)", + "sessionTtl": "会话 TTL(秒)", + "concurrency": "并发数" + }, + "masque": { + "addressPool": "地址池", + "addressPoolDesc": "隧道地址从这些前缀中分配——最多一个 IPv4 和一个 IPv6。" + }, + "tunDesc": "适配器类型", + "tunDescTooltip": "仅限 Windows:新建 Wintun 适配器时使用的隧道类型,留空即为 Wintun。", + "autoSystemDnsToGateway": "系统 DNS 指向网关", + "autoSystemDnsToGatewayTooltip": "仅限 Linux:将 systemd-resolved 指向 TUN 网关,使系统解析经过 Xray。需要设置网关,失败时 TUN 不会启动。", + "autoSystemWfpBlockLeak": "阻止泄漏 (WFP)", + "autoSystemWfpBlockLeakTooltip": "仅限 Windows,需配合自动系统路由:dns 让 DNS 只走 TUN(需设置 DNS 服务器),misconfigtun 阻止没有任何路由指向 TUN 的 IP 版本。" }, "info": { "mode": "模式", @@ -789,7 +823,7 @@ "limitIpFail2banWindows": "Windows 上不支持 Fail2ban,无法实施 IP 限制。", "limitIpDisabled": "此服务器已禁用 IP 限制功能。", "password": "密码", - "passwordDesc": "用于 Trojan、Shadowsocks 和 TUIC 客户端;对 VLESS、VMess、Hysteria 和 WireGuard 忽略。", + "passwordDesc": "用于 Trojan、Shadowsocks、TUIC 和 MASQUE 客户端;对 VLESS、VMess、Hysteria 和 WireGuard 忽略。", "subId": "订阅 ID", "subIdDesc": "同时用作该客户端的 Telegram 机器人邀请码:任何人将其发送给机器人即会绑定到此客户端。请保持足够长且随机——过短或易猜的 ID 可能被他人抢先绑定。", "online": "在线", @@ -1932,6 +1966,7 @@ "serverNamePlaceholder": "服务器名", "verifyPeerName": "验证 peer 名称", "pinnedSha256": "Pinned SHA256", + "useSystemCA": "使用系统 CA", "shortId": "Short ID", "sockopts": "Sockopts", "keepAliveInterval": "keep alive 间隔", @@ -1939,7 +1974,10 @@ "interface": "接口", "proxyProtocol": "Proxy protocol", "tcpUserTimeoutMs": "TCP user timeout (ms)", - "tcpKeepAliveIdleS": "TCP keep-alive idle (s)" + "tcpKeepAliveIdleS": "TCP keep-alive idle (s)", + "masqueWarpDesc": "使用已注册的 ECDSA P-256 密钥通过 MASQUE 连接 Cloudflare WARP;host 和 path 默认指向 Cloudflare 端点。", + "masqueWarpPublicKey": "端点公钥", + "masqueWarpAddress": "隧道地址" }, "outbound": { "tag": "标签", @@ -2200,7 +2238,9 @@ "warpPlusData": "WARP+ 数据", "quota": "配额", "usage": "使用", - "addOutbound": "添加出站" + "addOutbound": "添加出站", + "masqueDesc": "为 MASQUE 单独注册一个 WARP 设备;上方的 WireGuard 注册不受影响。", + "addMasqueOutbound": "添加 WARP over MASQUE 出站" }, "dns": { "enable": "启用 DNS", @@ -2244,7 +2284,11 @@ "clearAll": "删除全部", "clearAllTitle": "删除所有 DNS 服务器?", "clearAllConfirm": "此操作将从列表中删除所有 DNS 服务器,且无法撤销。", - "dnsLeakWarning": "DNS 可能通过 localhost、明文 UDP/TCP、本地模式 DoH/DoQ、回退查询或 EDNS client IP 泄漏。重视隐私时请使用经路由的 DoH、在 hosts 中固定解析器,并禁用回退。" + "dnsLeakWarning": "DNS 可能通过 localhost、明文 UDP/TCP、本地模式 DoH/DoQ、回退查询或 EDNS client IP 泄漏。重视隐私时请使用经路由的 DoH、在 hosts 中固定解析器,并禁用回退。", + "script": "Lua 脚本", + "scriptDesc": "定义了 HandleDNSQuery 的 .lua 文件路径,可为绝对路径或相对于 Xray bin 目录的路径。设置后由脚本应答所有查询,只通过脚本自身的代码访问服务器。文件不存在或无效时 Xray 无法启动。", + "serverId": "ID", + "serverIdDesc": "Lua DNS 脚本用来选择此服务器的名称" }, "fakedns": { "add": "添加假 DNS", @@ -2252,7 +2296,10 @@ "poolSize": "池大小" }, "defaultOutbound": "默认出站", - "defaultOutboundDesc": "未匹配任何路由规则的流量走此出站(列表中的第一个出站)。" + "defaultOutboundDesc": "未匹配任何路由规则的流量走此出站(列表中的第一个出站)。", + "routingScript": "路由脚本", + "routingScriptDesc": "定义了 HandleRoute 的 .lua 文件路径,可为绝对路径或相对于 Xray bin 目录的路径。设置后由脚本选择所有路由,Xray 会忽略全部路由规则,包括面板的 api 规则:请把 api 入站路由到 api 出站,否则流量统计和客户端变更的实时生效都会停止。文件不存在或无效时 Xray 无法启动。", + "routingScriptActive": "已在“{section}”中设置路由脚本,因此 Xray 会忽略这些规则。" }, "hosts": { "addHost": "添加主机", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 16e4d8547..d6a3617b3 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -694,7 +694,41 @@ "listen": "入站監聽地址", "custom": "自訂" }, - "autoFill": "自動填入" + "autoFill": "自動填入", + "xdrive": { + "service": "儲存服務", + "serviceLocal": "本機資料夾", + "serviceTemplate": "自訂範本", + "remoteFolder": "遠端資料夾", + "clientId": "用戶端 ID", + "clientSecret": "用戶端密鑰", + "refreshToken": "重新整理權杖", + "secrets": "密鑰", + "secretsDesc": "範本中以 {secret0}、{secret1}… 引用的值", + "template": "儲存範本", + "templateInvalid": "必須是 JSON 物件", + "front": "前置位址", + "frontDesc": "連線儲存 API 時使用的選用主機或 IP(網域前置)。", + "frontPort": "前置連接埠", + "segmentBytes": "分段大小(位元組)", + "flushInterval": "傳送間隔(毫秒)", + "pollInterval": "輪詢間隔(毫秒)", + "maxPollInterval": "最大輪詢間隔(毫秒)", + "eagerWindow": "預發視窗(毫秒)", + "holeTimeout": "遺失分段逾時(毫秒)", + "sessionTtl": "工作階段 TTL(秒)", + "concurrency": "並行數" + }, + "masque": { + "addressPool": "位址池", + "addressPoolDesc": "通道位址從這些前綴中分配——最多一個 IPv4 與一個 IPv6。" + }, + "tunDesc": "介面卡類型", + "tunDescTooltip": "僅限 Windows:新建 Wintun 介面卡時使用的通道類型,留空即為 Wintun。", + "autoSystemDnsToGateway": "系統 DNS 指向閘道", + "autoSystemDnsToGatewayTooltip": "僅限 Linux:將 systemd-resolved 指向 TUN 閘道,使系統解析經過 Xray。需要設定閘道,失敗時 TUN 不會啟動。", + "autoSystemWfpBlockLeak": "阻止洩漏 (WFP)", + "autoSystemWfpBlockLeakTooltip": "僅限 Windows,需搭配自動系統路由:dns 讓 DNS 只走 TUN(需設定 DNS 伺服器),misconfigtun 阻止沒有任何路由指向 TUN 的 IP 版本。" }, "info": { "mode": "模式", @@ -789,7 +823,7 @@ "limitIpFail2banWindows": "Windows 上不支援 Fail2ban,無法實施 IP 限制。", "limitIpDisabled": "此伺服器已停用 IP 限制功能。", "password": "密碼", - "passwordDesc": "僅 Trojan 與 Shadowsocks 用戶端使用;VLESS、VMess、Hysteria 和 WireGuard 會忽略此項。", + "passwordDesc": "用於 Trojan、Shadowsocks、TUIC 與 MASQUE 用戶端;VLESS、VMess、Hysteria 和 WireGuard 會忽略此項。", "subId": "訂閱 ID", "subIdDesc": "同時作為此用戶端的 Telegram 機器人邀請碼:任何人將其傳送給機器人即會綁定到此用戶端。請保持足夠長且隨機——過短或容易猜到的 ID 可能被他人搶先綁定。", "online": "上線", @@ -1932,6 +1966,7 @@ "serverNamePlaceholder": "伺服器名稱", "verifyPeerName": "驗證 peer 名稱", "pinnedSha256": "Pinned SHA256", + "useSystemCA": "使用系統 CA", "shortId": "Short ID", "sockopts": "Sockopts", "keepAliveInterval": "keep alive 間隔", @@ -1939,7 +1974,10 @@ "interface": "介面", "proxyProtocol": "Proxy protocol", "tcpUserTimeoutMs": "TCP user timeout (ms)", - "tcpKeepAliveIdleS": "TCP keep-alive idle (s)" + "tcpKeepAliveIdleS": "TCP keep-alive idle (s)", + "masqueWarpDesc": "使用已註冊的 ECDSA P-256 金鑰透過 MASQUE 連線 Cloudflare WARP;host 與 path 預設為 Cloudflare 端點。", + "masqueWarpPublicKey": "端點公開金鑰", + "masqueWarpAddress": "通道位址" }, "outbound": { "tag": "標籤", @@ -2200,7 +2238,9 @@ "warpPlusData": "WARP+ 資料", "quota": "配額", "usage": "使用", - "addOutbound": "新增出站" + "addOutbound": "新增出站", + "masqueDesc": "為 MASQUE 另外註冊一個 WARP 裝置;上方的 WireGuard 註冊不受影響。", + "addMasqueOutbound": "新增 WARP over MASQUE 出站" }, "dns": { "enable": "啟用 DNS", @@ -2244,7 +2284,11 @@ "clearAll": "全部刪除", "clearAllTitle": "刪除所有 DNS 伺服器?", "clearAllConfirm": "此操作將從清單中刪除所有 DNS 伺服器,無法復原。", - "dnsLeakWarning": "DNS 可能透過 localhost、明文 UDP/TCP、本機模式 DoH/DoQ、回退查詢或 EDNS client IP 洩漏。重視隱私時請使用經路由的 DoH、在 hosts 固定解析器,並停用回退。" + "dnsLeakWarning": "DNS 可能透過 localhost、明文 UDP/TCP、本機模式 DoH/DoQ、回退查詢或 EDNS client IP 洩漏。重視隱私時請使用經路由的 DoH、在 hosts 固定解析器,並停用回退。", + "script": "Lua 腳本", + "scriptDesc": "定義了 HandleDNSQuery 的 .lua 檔案路徑,可為絕對路徑或相對於 Xray bin 資料夾的路徑。設定後由腳本回應所有查詢,只透過腳本自身的程式碼存取伺服器。檔案不存在或無效時 Xray 無法啟動。", + "serverId": "ID", + "serverIdDesc": "Lua DNS 腳本用來選擇此伺服器的名稱" }, "fakedns": { "add": "新增假 DNS", @@ -2252,7 +2296,10 @@ "poolSize": "池大小" }, "defaultOutbound": "預設出站", - "defaultOutboundDesc": "未符合任何路由規則的流量走此出站(清單中的第一個出站)。" + "defaultOutboundDesc": "未符合任何路由規則的流量走此出站(清單中的第一個出站)。", + "routingScript": "路由腳本", + "routingScriptDesc": "定義了 HandleRoute 的 .lua 檔案路徑,可為絕對路徑或相對於 Xray bin 資料夾的路徑。設定後由腳本選擇所有路由,Xray 會忽略全部路由規則,包括面板的 api 規則:請將 api 入站路由到 api 出站,否則流量統計與用戶端變更的即時套用都會停止。檔案不存在或無效時 Xray 無法啟動。", + "routingScriptActive": "已在「{section}」中設定路由腳本,因此 Xray 會忽略這些規則。" }, "hosts": { "addHost": "新增 Host", diff --git a/internal/xray/api.go b/internal/xray/api.go index b43e4f8b1..fee95d7e4 100644 --- a/internal/xray/api.go +++ b/internal/xray/api.go @@ -22,6 +22,7 @@ import ( wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard" "github.com/xtls/xray-core/app/proxyman/command" + "github.com/xtls/xray-core/app/router" routerService "github.com/xtls/xray-core/app/router/command" statsService "github.com/xtls/xray-core/app/stats/command" xnet "github.com/xtls/xray-core/common/net" @@ -29,6 +30,7 @@ import ( "github.com/xtls/xray-core/common/serial" "github.com/xtls/xray-core/infra/conf" hysteriaAccount "github.com/xtls/xray-core/proxy/hysteria/account" + "github.com/xtls/xray-core/proxy/masque" "github.com/xtls/xray-core/proxy/shadowsocks" "github.com/xtls/xray-core/proxy/shadowsocks_2022" "github.com/xtls/xray-core/proxy/trojan" @@ -262,16 +264,7 @@ func (x *XrayAPI) ApplyRoutingConfig(routing []byte) error { return common.NewError("xray RoutingServiceClient is not initialized") } - // Rules referencing geoip:/geosite: need the dat files; point xray-core's - // in-process loader at the panel's bin folder where they live. - ensureXrayAssetLocation() - - routerConf := new(conf.RouterConfig) - if err := json.Unmarshal(routing, routerConf); err != nil { - logger.Debug("Failed to unmarshal routing config:", err) - return err - } - config, err := routerConf.Build() + config, err := buildReloadableRouting(routing) if err != nil { logger.Debug("Failed to build routing config:", err) return err @@ -287,6 +280,21 @@ func (x *XrayAPI) ApplyRoutingConfig(routing []byte) error { return err } +// buildReloadableRouting builds the rules and balancers RoutingService.AddRule swaps in. +// The Lua routing script is fixed at core start, so its file is never resolved here. +func buildReloadableRouting(routing []byte) (*router.Config, error) { + // Rules referencing geoip:/geosite: need the dat files; point xray-core's + // in-process loader at the panel's bin folder where they live. + ensureXrayAssetLocation() + + routerConf := new(conf.RouterConfig) + if err := json.Unmarshal(routing, routerConf); err != nil { + return nil, err + } + routerConf.Script = "" + return routerConf.Build() +} + // BalancerInfo is the live state of one balancer inside the running core. type BalancerInfo struct { Tag string `json:"tag"` @@ -670,6 +678,15 @@ func buildUserAccount(protocolName string, user map[string]any) (*serial.TypedMe return serial.ToTypedMessage(&hysteriaAccount.Account{ Auth: auth, }), nil + case "masque": + pass, err := masqueUserPassword(user) + if err != nil { + return nil, err + } + + return serial.ToTypedMessage(&masque.Account{ + Password: pass, + }), nil case "wireguard": pubB64, err := getRequiredUserString(user, "publicKey") if err != nil { @@ -710,6 +727,15 @@ func buildUserAccount(protocolName string, user map[string]any) (*serial.TypedMe } } +// masqueUserPassword reads a MASQUE user's password: panel callers send the client's +// "password", the hot diff sends the user as emitted for the core, keyed "pass". +func masqueUserPassword(user map[string]any) (string, error) { + if pass, err := getOptionalUserString(user, "pass"); err != nil || pass != "" { + return pass, err + } + return getRequiredUserString(user, "password") +} + // AddUser adds a user to an inbound in the Xray core using the specified // protocol and user data. On a legacy shadowsocks inbound the add first drops // any existing holder of the email: that is the one inbound whose validator diff --git a/internal/xray/api_masque_test.go b/internal/xray/api_masque_test.go new file mode 100644 index 000000000..ffd03908d --- /dev/null +++ b/internal/xray/api_masque_test.go @@ -0,0 +1,43 @@ +package xray + +import ( + "testing" + + "github.com/xtls/xray-core/proxy/masque" +) + +// Panel paths hand AddUser the client's "password"; the hot diff hands it the user +// as GetXrayConfig emitted it, keyed "pass". Without an account the core adds a +// MASQUE user it can never authenticate. +func TestBuildUserAccountMasque(t *testing.T) { + tests := []struct { + name string + user map[string]any + }{ + {"panel client", map[string]any{"email": "ivy@example.test", "password": "pw-ivy"}}, + {"emitted core user", map[string]any{"email": "ivy@example.test", "pass": "pw-ivy"}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + typed, err := buildUserAccount("masque", tc.user) + if err != nil { + t.Fatalf("buildUserAccount: %v", err) + } + if typed == nil { + t.Fatal("buildUserAccount returned no account") + } + instance, err := typed.GetInstance() + if err != nil { + t.Fatalf("GetInstance: %v", err) + } + account, ok := instance.(*masque.Account) + if !ok || account.Password != "pw-ivy" { + t.Fatalf("account = %#v, want a masque.Account with password pw-ivy", instance) + } + }) + } + + if _, err := buildUserAccount("masque", map[string]any{"email": "ivy@example.test"}); err == nil { + t.Fatal("a MASQUE user without a password must be refused, not added unauthenticatable") + } +} diff --git a/internal/xray/api_routing_test.go b/internal/xray/api_routing_test.go new file mode 100644 index 000000000..cefb3c064 --- /dev/null +++ b/internal/xray/api_routing_test.go @@ -0,0 +1,22 @@ +package xray + +import "testing" + +// xray-core 26.10.10 resolves routing.script from the loader's working directory, and the +// running core keeps its script anyway: a script beside the xray binary failed every hot +// rule change panel-side, turning it into a full restart and breaking the node bridge. +func TestBuildReloadableRouting_IgnoresLuaScript(t *testing.T) { + t.Chdir(t.TempDir()) + routing := []byte(`{"script":"route.lua","rules":[{"type":"field","ip":["10.0.0.0/8"],"outboundTag":"direct"}]}`) + + config, err := buildReloadableRouting(routing) + if err != nil { + t.Fatalf("buildReloadableRouting: %v", err) + } + if config.Script != "" { + t.Fatalf("Script = %q, want it left to the running core", config.Script) + } + if len(config.Rule) != 1 || config.Rule[0].GetTag() != "direct" { + t.Fatalf("rules = %v, want the one rule to direct", config.Rule) + } +} diff --git a/internal/xray/hot_diff.go b/internal/xray/hot_diff.go index b2319bb70..453dfdabd 100644 --- a/internal/xray/hot_diff.go +++ b/internal/xray/hot_diff.go @@ -221,7 +221,7 @@ func droppedClients(oldIb, newIb *InboundConfig) []UserOp { return dropped } -var userDiffableProtocols = map[string]struct{}{"vless": {}, "vmess": {}, "trojan": {}, "hysteria": {}} +var userDiffableProtocols = map[string]struct{}{"vless": {}, "vmess": {}, "trojan": {}, "hysteria": {}, "masque": {}} // diffInboundUsers emits per-user AlterInbound ops when two same-tag inbounds // differ only in settings.clients, so the handler (and its listener) survives. diff --git a/internal/xray/hot_diff_test.go b/internal/xray/hot_diff_test.go index 9acaa46c4..1d694ec49 100644 --- a/internal/xray/hot_diff_test.go +++ b/internal/xray/hot_diff_test.go @@ -211,6 +211,29 @@ func TestComputeHotDiff_ClientOnlyChangeUsesUserOps(t *testing.T) { } } +// A MASQUE inbound carries every user's CONNECT-IP tunnel; replacing the handler to +// add one user would drop all of them, so its client list diffs per user too. +func TestComputeHotDiff_MasqueClientAddUsesUserOps(t *testing.T) { + oldCfg := makeHotConfig() + oldCfg.InboundConfigs[1].Protocol = "masque" + oldCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[{"email":"a","pass":"pw-a"}],"address":["10.14.0.1/24"]}`) + newCfg := makeHotConfig() + newCfg.InboundConfigs[1].Protocol = "masque" + newCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[{"email":"a","pass":"pw-a"},{"email":"b","pass":"pw-b"}],"address":["10.14.0.1/24"]}`) + + diff, ok := ComputeHotDiff(oldCfg, newCfg) + if !ok { + t.Fatal("a MASQUE client-only change must be hot-appliable") + } + if len(diff.RemovedInboundTags) != 0 || len(diff.AddedInbounds) != 0 { + t.Fatalf("a MASQUE client add must not replace the handler, got %+v", diff) + } + if len(diff.RemovedUsers) != 0 || len(diff.AddedUsers) != 1 || + diff.AddedUsers[0].Email != "b" || diff.AddedUsers[0].User["pass"] != "pw-b" { + t.Fatalf("expected only user b added with its pass, got added %+v removed %+v", diff.AddedUsers, diff.RemovedUsers) + } +} + func TestComputeHotDiff_ClientChangeFallsBackToReplace(t *testing.T) { cases := []struct { name string