1
0

9 Коммиты 442b7fb163 ... ec0a57fc4f

Автор SHA1 Сообщение Дата
  MHSanaei ec0a57fc4f fix(finalmask): hop client UDP through the udphop mask 13 часов назад
  MHSanaei 00aaef386f fix(inbounds): accept masque in the inbound protocol validator 13 часов назад
  MHSanaei da6872a3b8 chore(frontend): let lint-staged pass commits that stage only generated files 13 часов назад
  MHSanaei 04cccef311 feat(warp): register WARP over MASQUE from the WARP modal 13 часов назад
  MHSanaei b04039c2bd feat(xray): add the MASQUE protocol and transport 13 часов назад
  MHSanaei ca5983595b feat(xray): add the XDRIVE transport 16 часов назад
  MHSanaei 73a028f52d feat(xray): update xray-core to v26.10.10 and adapt panel 16 часов назад
  MHSanaei 82c7711189 chore(deps): update Go and dependencies 16 часов назад
  Leslie Alexander 66234315e4 fix(panel): default TLS ALPN to http/1.1 for WebSocket transport (#6787) 17 часов назад
100 измененных файлов с 2588 добавлено и 211 удалено
  1. 2 2
      .github/workflows/release.yml
  2. 1 1
      DockerInit.sh
  3. 1 0
      docs/content/docs/en/config/inbounds.mdx
  4. 59 0
      docs/content/docs/en/config/masque.mdx
  5. 1 0
      docs/content/docs/en/config/meta.json
  6. 31 5
      docs/content/docs/en/config/transports.mdx
  7. 1 0
      docs/content/docs/fa/config/inbounds.mdx
  8. 60 0
      docs/content/docs/fa/config/masque.mdx
  9. 1 0
      docs/content/docs/fa/config/meta.json
  10. 31 5
      docs/content/docs/fa/config/transports.mdx
  11. 1 0
      docs/content/docs/ru/config/inbounds.mdx
  12. 59 0
      docs/content/docs/ru/config/masque.mdx
  13. 1 0
      docs/content/docs/ru/config/meta.json
  14. 31 5
      docs/content/docs/ru/config/transports.mdx
  15. 1 0
      docs/content/docs/zh/config/inbounds.mdx
  16. 55 0
      docs/content/docs/zh/config/masque.mdx
  17. 1 0
      docs/content/docs/zh/config/meta.json
  18. 26 3
      docs/content/docs/zh/config/transports.mdx
  19. 3 2
      docs/public/openapi.json
  20. 2 2
      frontend/package.json
  21. 3 2
      frontend/public/openapi.json
  22. 1 0
      frontend/src/components/command-palette/CommandPalette.tsx
  23. 2 1
      frontend/src/generated/schemas.ts
  24. 1 1
      frontend/src/generated/zod.ts
  25. 3 0
      frontend/src/lib/xray/forms/fields/FinalMaskField.tsx
  26. 154 39
      frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx
  27. 148 0
      frontend/src/lib/xray/forms/transport/XDriveForm.tsx
  28. 12 1
      frontend/src/lib/xray/inbound-defaults.ts
  29. 5 0
      frontend/src/lib/xray/inbound-form-adapter.ts
  30. 7 0
      frontend/src/lib/xray/inbound-link.ts
  31. 13 0
      frontend/src/lib/xray/inbound-tag.ts
  32. 11 1
      frontend/src/lib/xray/inbound-tls-defaults.ts
  33. 1 0
      frontend/src/lib/xray/node-protocols.ts
  34. 8 0
      frontend/src/lib/xray/outbound-defaults.ts
  35. 25 1
      frontend/src/lib/xray/outbound-form-adapter.ts
  36. 4 3
      frontend/src/lib/xray/outbound-link-parser.ts
  37. 2 1
      frontend/src/lib/xray/protocol-capabilities.ts
  38. 4 0
      frontend/src/lib/xray/stream-defaults.ts
  39. 29 0
      frontend/src/lib/xray/udphop-mask.ts
  40. 49 18
      frontend/src/lib/xray/xdns-mask.ts
  41. 1 1
      frontend/src/pages/api-docs/endpoints.ts
  42. 1 0
      frontend/src/pages/clients/BulkAttachInboundsModal.tsx
  43. 1 0
      frontend/src/pages/clients/BulkDetachInboundsModal.tsx
  44. 1 0
      frontend/src/pages/clients/ClientBulkAddModal.tsx
  45. 1 0
      frontend/src/pages/clients/ClientFormModal.tsx
  46. 1 0
      frontend/src/pages/hosts/HostList.tsx
  47. 1 0
      frontend/src/pages/inbounds/InboundsPage.tsx
  48. 46 3
      frontend/src/pages/inbounds/form/InboundFormModal.tsx
  49. 1 0
      frontend/src/pages/inbounds/form/protocols/index.ts
  50. 35 0
      frontend/src/pages/inbounds/form/protocols/masque.tsx
  51. 2 1
      frontend/src/pages/inbounds/form/useSecurityActions.ts
  52. 1 0
      frontend/src/pages/inbounds/list/helpers.ts
  53. 1 0
      frontend/src/pages/inbounds/useInbounds.ts
  54. 1 0
      frontend/src/pages/settings/SubBalancerFormModal.tsx
  55. 5 0
      frontend/src/pages/xray/dns/DnsServerModal.tsx
  56. 28 3
      frontend/src/pages/xray/outbounds/OutboundFormModal.tsx
  57. 5 0
      frontend/src/pages/xray/outbounds/outbound-form-constants.ts
  58. 13 0
      frontend/src/pages/xray/outbounds/outbound-form-helpers.ts
  59. 2 1
      frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts
  60. 1 0
      frontend/src/pages/xray/outbounds/protocols/index.ts
  61. 18 0
      frontend/src/pages/xray/outbounds/protocols/masque.tsx
  62. 8 1
      frontend/src/pages/xray/outbounds/security/tls.tsx
  63. 1 0
      frontend/src/pages/xray/outbounds/transport/index.ts
  64. 92 0
      frontend/src/pages/xray/outbounds/transport/masque.tsx
  65. 52 0
      frontend/src/pages/xray/overrides/WarpModal.tsx
  66. 2 0
      frontend/src/schemas/dns.ts
  67. 8 0
      frontend/src/schemas/forms/outbound-form.ts
  68. 1 0
      frontend/src/schemas/primitives/outbound-protocol.ts
  69. 2 0
      frontend/src/schemas/primitives/protocol.ts
  70. 3 0
      frontend/src/schemas/protocols/inbound/index.ts
  71. 33 0
      frontend/src/schemas/protocols/inbound/masque.ts
  72. 3 0
      frontend/src/schemas/protocols/outbound/index.ts
  73. 12 0
      frontend/src/schemas/protocols/outbound/masque.ts
  74. 17 0
      frontend/src/schemas/protocols/stream/index.ts
  75. 26 0
      frontend/src/schemas/protocols/stream/masque.ts
  76. 26 0
      frontend/src/schemas/protocols/stream/xdrive.ts
  77. 9 6
      frontend/src/test/__snapshots__/finalmask.test.ts.snap
  78. 89 0
      frontend/src/test/__snapshots__/inbound-full.test.ts.snap
  79. 2 0
      frontend/src/test/__snapshots__/inbound-link.test.ts.snap
  80. 25 0
      frontend/src/test/__snapshots__/stream.test.ts.snap
  81. 2 2
      frontend/src/test/client-form-modal.test.tsx
  82. 30 0
      frontend/src/test/dns-server-modal.test.tsx
  83. 3 3
      frontend/src/test/golden/fixtures/finalmask/udp-mask.json
  84. 70 0
      frontend/src/test/golden/fixtures/inbound-full/masque-tls.json
  85. 22 0
      frontend/src/test/golden/fixtures/stream/xdrive-google-drive.json
  86. 21 1
      frontend/src/test/inbound-defaults.test.ts
  87. 149 0
      frontend/src/test/inbound-form-modal.test.tsx
  88. 22 0
      frontend/src/test/inbound-link.test.ts
  89. 21 0
      frontend/src/test/inbound-tag.test.ts
  90. 306 13
      frontend/src/test/outbound-form-modal.test.tsx
  91. 42 12
      frontend/src/test/outbound-link-parser.test.ts
  92. 26 0
      frontend/src/test/use-xray-setting.test.tsx
  93. 100 0
      frontend/src/test/warp-masque-modal.test.tsx
  94. 18 15
      go.mod
  95. 41 30
      go.sum
  96. 81 15
      internal/database/db.go
  97. 46 1
      internal/database/model/model.go
  98. 117 0
      internal/database/udphop_client_mask_migration_test.go
  99. 12 10
      internal/database/xdns_finalmask_migration_test.go
  100. 32 0
      internal/sub/json_service.go

+ 2 - 2
.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"

+ 1 - 1
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}"

+ 1 - 0
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). |
 
 <Callout type="info">
   Hysteria2 isn't a separate protocol internally — it's the `hysteria` protocol

+ 59 - 0
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. |
+
+<Callout type="info">
+  MASQUE has no share-link format, so MASQUE inbounds get no link, QR code or Clash
+  entry. Clients get a ready-to-import config from the **JSON subscription**: a `masque`
+  outbound that authenticates with the client's email and password.
+</Callout>
+
+## Outbound
+
+Pick **masque** as the outbound protocol, then set:
+
+| Field                   | Description |
+| ----------------------- | ----------- |
+| **Address / Port**      | The MASQUE server. |
+| **Remote DNS**          | Optional DNS server IPs queried inside the tunnel. |
+| **Host / Path**         | Authority and path of the CONNECT-IP request; the path must match the server's. |
+| **Username / Password** | HTTP Basic credentials — on a 3x-ui server, the client's email and password. |
+| **Headers**             | Extra request headers. |
+| **TLS**                 | Required. An ALPN of `h2` alone switches to HTTP/2 over TCP; otherwise HTTP/3 is used. |
+
+## WARP over MASQUE
+
+The quickest way is **Xray → Outbounds → WARP → Add WARP over MASQUE outbound**: the panel
+registers a separate WARP device, enrolls a MASQUE key for it and adds a ready `warp-masque`
+outbound (or refreshes the existing one). Your WireGuard WARP registration is not touched.
+To set it up by hand instead:
+
+Turn on **WARP** in the masque transport to reach Cloudflare WARP through its MASQUE
+endpoint instead of WireGuard. It takes a WARP registration enrolled for MASQUE:
+
+| Field                   | Description |
+| ----------------------- | ----------- |
+| **Private key**         | The enrolled ECDSA P-256 private key (PEM, or base64 DER). |
+| **Endpoint public key** | Cloudflare's endpoint public key returned by the enrollment. |
+| **Tunnel addresses**    | The IPv4 and IPv6 addresses Cloudflare assigned to the registration. |
+
+Host and path then default to Cloudflare's endpoint, and WARP can't be combined with a
+username or password. Point the outbound at the endpoint address from your enrollment and
+set the TLS server name to `consumer-masque.cloudflareclient.com`.

+ 1 - 0
docs/content/docs/en/config/meta.json

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

+ 31 - 5
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). |
 
 <Callout type="info">
   **WireGuard** and **Tunnel** (dokodemo-door) inbounds expose no transport
@@ -116,6 +118,25 @@ 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.
+
 ## FinalMask — late-layer obfuscation
 
 **FinalMask** wraps traffic **after** the transport and security layers, so it can
@@ -126,11 +147,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.

+ 1 - 0
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). |
 
 <Callout type="info">
   Hysteria2 در سطح داخلی یک پروتکل جداگانه نیست — همان پروتکل `hysteria` است که

+ 60 - 0
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 را سرو می‌کند و هر دو با هم، هر دو را. |
+
+<Callout type="info">
+  MASQUE فرمت لینک اشتراک‌گذاری ندارد، بنابراین inboundهای MASQUE لینک، کد QR یا ورودی
+  Clash ندارند. کلاینت‌ها کانفیگ آمادهٔ import را از **اشتراک JSON** می‌گیرند: یک outbound
+  از نوع `masque` که با ایمیل و رمز عبور کلاینت احراز هویت می‌کند.
+</Callout>
+
+## Outbound
+
+**masque** را به‌عنوان پروتکل outbound انتخاب کنید و سپس تنظیم کنید:
+
+| فیلد                       | توضیح |
+| -------------------------- | ----- |
+| **آدرس / پورت**            | سرور MASQUE. |
+| **Remote DNS**             | IPهای اختیاری سرور DNS که داخل تونل پرس‌وجو می‌شوند. |
+| **Host / Path**            | authority و مسیر درخواست CONNECT-IP؛ مسیر باید با مسیر سرور یکسان باشد. |
+| **نام کاربری / رمز عبور**  | اعتبارنامهٔ HTTP Basic — روی سرور 3x-ui، همان ایمیل و رمز عبور کلاینت. |
+| **Headers**                | هدرهای اضافی درخواست. |
+| **TLS**                    | الزامی. ALPN فقط `h2` اتصال را به HTTP/2 روی TCP می‌برد؛ در غیر این صورت HTTP/3 استفاده می‌شود. |
+
+## WARP از طریق MASQUE
+
+ساده‌ترین راه **Xray ← Outbounds ← WARP ← افزودن outbound برای WARP روی MASQUE** است: پنل یک
+دستگاه WARP جداگانه ثبت می‌کند، برای آن کلید MASQUE ثبت می‌کند و یک outbound آمادهٔ `warp-masque`
+اضافه می‌کند (یا outbound موجود را تازه می‌کند). ثبت WARP وایرگارد شما دست نمی‌خورد.
+برای راه‌اندازی دستی:
+
+**WARP** را در transport مربوط به masque روشن کنید تا به‌جای WireGuard از طریق endpoint
+مخصوص MASQUE به Cloudflare WARP برسید. این کار به یک ثبت‌نام WARP نیاز دارد که برای MASQUE
+ثبت شده باشد:
+
+| فیلد                     | توضیح |
+| ------------------------ | ----- |
+| **کلید خصوصی**           | کلید خصوصی ECDSA P-256 ثبت‌شده (PEM یا DER با base64). |
+| **کلید عمومی endpoint**  | کلید عمومی endpoint کلودفلر که در ثبت‌نام برگردانده می‌شود. |
+| **آدرس‌های تونل**        | آدرس‌های IPv4 و IPv6 که کلودفلر به این ثبت‌نام اختصاص داده است. |
+
+در این حالت host و path به‌طور پیش‌فرض endpoint کلودفلر هستند و WARP را نمی‌توان با نام
+کاربری یا رمز عبور ترکیب کرد. outbound را به آدرس endpoint حاصل از ثبت‌نام خود هدایت کنید و
+نام سرور TLS را `consumer-masque.cloudflareclient.com` قرار دهید.

+ 1 - 0
docs/content/docs/fa/config/meta.json

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

+ 31 - 5
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). |
 
 <Callout type="info">
   inbound‌های **WireGuard** و **Tunnel** (dokodemo-door) هیچ انتخابگر انتقالی
@@ -117,6 +119,25 @@ 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 ذخیره‌ساز است.
+
 ## FinalMask — مبهم‌سازی لایه پایانی
 
 **FinalMask** ترافیک را **پس از** لایه‌های انتقال و امنیت می‌پیچد، بنابراین می‌تواند
@@ -127,11 +148,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 نمایش می‌دادند.

+ 1 - 0
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). |
 
 <Callout type="info">
   Hysteria2 внутренне не является отдельным протоколом — это протокол `hysteria`

+ 59 - 0
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, вместе — оба. |
+
+<Callout type="info">
+  У MASQUE нет формата ссылок, поэтому для инбаундов MASQUE не создаются ссылка, QR-код и
+  запись Clash. Клиенты получают готовую к импорту конфигурацию из **JSON-подписки**:
+  аутбаунд `masque`, который аутентифицируется email и паролем клиента.
+</Callout>
+
+## Аутбаунд
+
+Выберите протокол аутбаунда **masque** и задайте:
+
+| Поле                       | Описание |
+| -------------------------- | -------- |
+| **Адрес / Порт**           | Сервер MASQUE. |
+| **Remote DNS**             | Необязательные IP DNS-серверов, к которым идут запросы внутри туннеля. |
+| **Host / Path**            | Authority и путь запроса CONNECT-IP; путь должен совпадать с путём сервера. |
+| **Имя пользователя / Пароль** | Учётные данные HTTP Basic — на сервере 3x-ui это email и пароль клиента. |
+| **Headers**                | Дополнительные заголовки запроса. |
+| **TLS**                    | Обязательно. ALPN только с `h2` переключает на HTTP/2 по TCP; иначе используется HTTP/3. |
+
+## WARP через MASQUE
+
+Проще всего: **Xray → Аутбаунды → WARP → Добавить аутбаунд WARP через MASQUE** — панель
+регистрирует отдельное устройство WARP, привязывает к нему ключ MASQUE и добавляет готовый
+аутбаунд `warp-masque` (или обновляет существующий). Регистрация WARP для WireGuard не меняется.
+Для ручной настройки:
+
+Включите **WARP** в транспорте masque, чтобы подключаться к Cloudflare WARP через его
+MASQUE-эндпоинт вместо WireGuard. Для этого нужна регистрация WARP, привязанная к MASQUE:
+
+| Поле                       | Описание |
+| -------------------------- | -------- |
+| **Закрытый ключ**          | Зарегистрированный закрытый ключ ECDSA P-256 (PEM или DER в base64). |
+| **Открытый ключ эндпоинта** | Открытый ключ эндпоинта Cloudflare, возвращённый при регистрации. |
+| **Адреса туннеля**         | Адреса IPv4 и IPv6, которые Cloudflare назначил регистрации. |
+
+В этом случае host и path по умолчанию указывают на эндпоинт Cloudflare, а WARP нельзя
+сочетать с именем пользователя или паролем. Направьте аутбаунд на адрес эндпоинта из вашей
+регистрации и укажите имя сервера TLS `consumer-masque.cloudflareclient.com`.

+ 1 - 0
docs/content/docs/ru/config/meta.json

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

+ 31 - 5
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). |
 
 <Callout type="info">
   Inbound-соединения **WireGuard** и **Tunnel** (dokodemo-door) не предоставляют
@@ -119,6 +121,25 @@ 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 хранилища.
+
 ## FinalMask — обфускация на позднем уровне
 
 **FinalMask** оборачивает трафик **после** уровней транспорта и безопасности,
@@ -129,11 +150,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.

+ 1 - 0
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)。 |
 
 <Callout type="info">
   在内部,Hysteria2 并不是一个独立的协议——它是把传输版本设为 2 的 `hysteria`

+ 55 - 0
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,两者同时设置则都提供。 |
+
+<Callout type="info">
+  MASQUE 没有分享链接格式,因此 MASQUE 入站不生成链接、二维码或 Clash 条目。客户端通过 **JSON 订阅**获取可直接导入的配置:
+  一个使用该客户端邮箱和密码认证的 `masque` 出站。
+</Callout>
+
+## 出站
+
+选择 **masque** 作为出站协议,然后设置:
+
+| 字段                | 说明 |
+| ------------------- | ---- |
+| **地址 / 端口**     | MASQUE 服务器。 |
+| **Remote DNS**      | 可选,在隧道内查询的 DNS 服务器 IP。 |
+| **Host / Path**     | CONNECT-IP 请求的 authority 和路径;路径必须与服务端一致。 |
+| **用户名 / 密码**   | HTTP Basic 凭据——在 3x-ui 服务端上即客户端的邮箱和密码。 |
+| **Headers**         | 额外的请求头。 |
+| **TLS**             | 必需。ALPN 仅为 `h2` 时改用 TCP 上的 HTTP/2;否则使用 HTTP/3。 |
+
+## 通过 MASQUE 使用 WARP
+
+最简单的方式是 **Xray → 出站 → WARP → 添加 WARP over MASQUE 出站**:面板会单独注册一个 WARP 设备、为其注册 MASQUE 密钥,
+并添加一个可直接使用的 `warp-masque` 出站(若已存在则更新)。你的 WireGuard WARP 注册不受影响。
+如需手动设置:
+
+在 masque 传输中开启 **WARP**,即可通过 Cloudflare 的 MASQUE 端点而非 WireGuard 连接 WARP。这需要一个已为
+MASQUE 注册的 WARP 账户:
+
+| 字段             | 说明 |
+| ---------------- | ---- |
+| **私钥**         | 已注册的 ECDSA P-256 私钥(PEM 或 base64 编码的 DER)。 |
+| **端点公钥**     | 注册时返回的 Cloudflare 端点公钥。 |
+| **隧道地址**     | Cloudflare 为该注册分配的 IPv4 和 IPv6 地址。 |
+
+此时 host 和 path 默认指向 Cloudflare 端点,且 WARP 不能与用户名或密码同时使用。请将出站指向注册所得的端点地址,
+并将 TLS 服务器名称设为 `consumer-masque.cloudflareclient.com`。

+ 1 - 0
docs/content/docs/zh/config/meta.json

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

+ 26 - 3
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)。 |
 
 <Callout type="info">
   **WireGuard** 和 **Tunnel**(dokodemo-door)入站不提供传输方式选择器——它们的传输流
@@ -112,6 +114,22 @@ 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。
+
 ## FinalMask — 末层混淆
 
 **FinalMask** 在传输方式和安全层**之后**包裹流量,因此它既能伪装那些承载不了 TLS 的
@@ -120,9 +138,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` 混淆。
 

+ 3 - 2
docs/public/openapi.json

@@ -3115,7 +3115,8 @@
               "tun",
               "mtproto",
               "amneziawg",
-              "tuic"
+              "tuic",
+              "masque"
             ],
             "example": "vless",
             "type": "string"
@@ -13649,7 +13650,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"
             }

+ 2 - 2
frontend/package.json

@@ -29,8 +29,8 @@
   },
   "lint-staged": {
     "src/**/*.{ts,tsx}": [
-      "oxfmt",
-      "oxlint --fix"
+      "oxfmt --no-error-on-unmatched-pattern",
+      "oxlint --fix --no-error-on-unmatched-pattern"
     ]
   },
   "dependencies": {

+ 3 - 2
frontend/public/openapi.json

@@ -3115,7 +3115,8 @@
               "tun",
               "mtproto",
               "amneziawg",
-              "tuic"
+              "tuic",
+              "masque"
             ],
             "example": "vless",
             "type": "string"
@@ -13649,7 +13650,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"
             }

+ 1 - 0
frontend/src/components/command-palette/CommandPalette.tsx

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

+ 2 - 1
frontend/src/generated/schemas.ts

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

+ 1 - 1
frontend/src/generated/zod.ts

@@ -757,7 +757,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(),

+ 3 - 0
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}
       />
     </Form>
   );

+ 154 - 39
frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx

@@ -18,6 +18,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 +35,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
@@ -194,6 +198,8 @@ function defaultUdpMaskSettings(type: string): Record<string, unknown> {
       return { client: [], server: [] };
     case 'noise':
       return { reset: 0, noise: [] };
+    case 'udphop':
+      return { mode: 'intervalRemote', interval: '5-10', remotePorts: '' };
     default:
       return {};
   }
@@ -242,11 +248,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 +280,13 @@ export default function FinalMaskForm({
       const { next, changed } = upgradeLegacyXdnsMasks(udp);
       if (changed) form.setFieldValue([...base, 'udp'], next);
     }
+    if (side === 'client') {
+      const finalmask = form.getFieldValue(base) as Record<string, unknown> | undefined;
+      if (finalmask && typeof finalmask === 'object') {
+        const { next, changed } = upgradeLegacyUdpHop(finalmask);
+        if (changed) form.setFieldValue(base, next);
+      }
+    }
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, []);
 
@@ -282,7 +296,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 +319,7 @@ export default function FinalMaskForm({
           isHysteria={isHysteria}
           isWireguard={isWireguard}
           network={network}
+          allowUdpHop={side === 'client'}
         />
       )}
       {showQuic && (
@@ -311,7 +332,13 @@ export default function FinalMaskForm({
               }}
             />
           </Form.Item>
-          {hasQuicParams && <QuicParamsForm base={[...base, 'quicParams']} form={form} />}
+          {hasQuicParams && (
+            <QuicParamsForm
+              base={[...base, 'quicParams']}
+              form={form}
+              showUdpHop={side === 'server'}
+            />
+          )}
         </>
       )}
     </>
@@ -820,12 +847,14 @@ function UdpMasksList({
   isHysteria,
   isWireguard,
   network,
+  allowUdpHop,
 }: {
   base: (string | number)[];
   form: FormInstance;
   isHysteria: boolean;
   isWireguard: boolean;
   network: string;
+  allowUdpHop: boolean;
 }) {
   const { t } = useTranslation();
   return (
@@ -854,6 +883,7 @@ function UdpMasksList({
               isHysteria={isHysteria}
               isWireguard={isWireguard}
               network={network}
+              allowUdpHop={allowUdpHop}
               onRemove={() => remove(field.name)}
             />
           ))}
@@ -871,6 +901,7 @@ function UdpMaskItem({
   isHysteria,
   isWireguard,
   network,
+  allowUdpHop,
   onRemove,
 }: {
   fieldName: number;
@@ -880,6 +911,7 @@ function UdpMaskItem({
   isHysteria: boolean;
   isWireguard: boolean;
   network: string;
+  allowUdpHop: boolean;
   onRemove: () => void;
 }) {
   const absolutePath = [...listPath, fieldName];
@@ -893,8 +925,9 @@ function UdpMaskItem({
     }
   };
 
+  const udpHopOption = allowUdpHop ? [{ value: 'udphop', label: 'UDP Hop' }] : [];
   const options = isHysteria
-    ? [{ value: 'salamander', label: 'Salamander (Hysteria2)' }]
+    ? [{ value: 'salamander', label: 'Salamander (Hysteria2)' }, ...udpHopOption]
     : [
         // Salamander is the mask xray-core's own wireguard finalmask example
         // uses; it stays hysteria-only elsewhere to keep legacy parity.
@@ -905,6 +938,7 @@ function UdpMaskItem({
         { value: 'realm', label: 'Realm' },
         { value: 'header-custom', label: 'Header Custom' },
         { value: 'noise', label: 'Noise' },
+        ...udpHopOption,
       ];
 
   return (
@@ -967,6 +1001,9 @@ function UdpMaskItem({
           if (type === 'xdns') {
             return <XdnsSettings udpFieldName={fieldName} />;
           }
+          if (type === 'udphop') {
+            return <UdpHopSettings udpFieldName={fieldName} />;
+          }
           if (type === 'xicmp') {
             return (
               <>
@@ -1280,7 +1317,7 @@ function XdnsSettings({ udpFieldName }: { udpFieldName: number }) {
                 size="small"
                 icon={<PlusOutlined />}
                 aria-label={t('add')}
-                onClick={() => add({ name: '', types: [16], edns0: XDNS_LEGACY_EDNS0 })}
+                onClick={() => add({ names: [], edns0: XDNS_LEGACY_EDNS0 })}
               />
             </Form.Item>
             {domains.map((domain, di) => (
@@ -1296,15 +1333,20 @@ function XdnsSettings({ udpFieldName }: { udpFieldName: number }) {
                     onKeyDown={activateOnKey(() => remove(domain.name))}
                   />
                 </Divider>
-                <Form.Item label="Name" name={[domain.name, 'name']}>
-                  <Input placeholder="t.example.com" />
+                <Form.Item label="Names" name={[domain.name, 'names']}>
+                  <Select
+                    mode="tags"
+                    style={{ width: '100%' }}
+                    tokenSeparators={[',']}
+                    placeholder="t.example.com"
+                  />
                 </Form.Item>
-                <Form.Item
-                  label="Record Types"
-                  name={[domain.name, 'types']}
-                  rules={[{ required: true, type: 'array', min: 1 }]}
-                >
-                  <Select mode="multiple" options={XDNS_RECORD_TYPE_OPTIONS} />
+                <Form.Item label="Record Types" name={[domain.name, 'types']}>
+                  <Select
+                    mode="multiple"
+                    options={XDNS_RECORD_TYPE_OPTIONS}
+                    placeholder="TXT (client), all (server)"
+                  />
                 </Form.Item>
                 <Form.Item label="EDNS0" name={[domain.name, 'edns0']}>
                   <InputNumber min={512} max={4096} placeholder="off" />
@@ -1329,24 +1371,20 @@ function XdnsSettings({ udpFieldName }: { udpFieldName: number }) {
                 size="small"
                 icon={<PlusOutlined />}
                 aria-label={t('add')}
-                onClick={() => add({ type: 'udp', settings: { addr: '' } })}
+                onClick={() => add({ addrs: [] })}
               />
             </Form.Item>
             {resolvers.map((resolver, ri) => (
               <Form.Item key={resolver.key} label={`Resolver ${ri + 1}`}>
                 <Space.Compact block>
-                  <Form.Item name={[resolver.name, 'type']} noStyle>
+                  <Form.Item name={[resolver.name, 'addrs']} noStyle>
                     <Select
-                      style={{ width: 80 }}
-                      options={[
-                        { value: 'udp', label: 'UDP' },
-                        { value: 'tcp', label: 'TCP' },
-                      ]}
+                      mode="tags"
+                      style={{ width: '100%' }}
+                      tokenSeparators={[',', ' ']}
+                      placeholder="udp://8.8.8.8:53, tcp://1.1.1.1"
                     />
                   </Form.Item>
-                  <Form.Item name={[resolver.name, 'settings', 'addr']} noStyle>
-                    <Input placeholder="8.8.8.8:53" />
-                  </Form.Item>
                   <Button
                     icon={<DeleteOutlined />}
                     aria-label={t('remove')}
@@ -1365,6 +1403,66 @@ function XdnsSettings({ udpFieldName }: { udpFieldName: number }) {
   );
 }
 
+const UDP_HOP_MODES = [
+  { value: 'intervalRemote', label: 'Rotate server port (interval)' },
+  { value: 'perConnRemote', label: 'Random server port per connection' },
+  { value: 'intervalLocal', label: 'Rotate local port (interval)' },
+];
+
+// xray-core lowercases each comma-separated mode, so a lowercase one (from an mport
+// import) still maps onto its option here.
+function udpHopModesFromWire(value: unknown): string[] {
+  if (typeof value !== 'string') return [];
+  return value
+    .split(',')
+    .map((mode) => mode.trim())
+    .filter(Boolean)
+    .map(
+      (mode) =>
+        UDP_HOP_MODES.find((m) => m.value.toLowerCase() === mode.toLowerCase())?.value ?? mode,
+    );
+}
+
+function validateUdpHopInterval(_: unknown, value: unknown) {
+  if (value == null || value === '') return Promise.resolve();
+  const bounds = String(value)
+    .split('-')
+    .map((v) => Number(v.trim()));
+  const valid = bounds.length <= 2 && bounds.every((v) => Number.isInteger(v) && v >= 5);
+  return valid ? Promise.resolve() : Promise.reject(new Error('seconds ≥ 5, e.g. 30 or 5-10'));
+}
+
+// Client-only hopping (xray-core 26.9.9); every key needs a registered field because the
+// finalmask watch drops keys without one.
+function UdpHopSettings({ udpFieldName }: { udpFieldName: number }) {
+  return (
+    <>
+      <Form.Item
+        label="Mode"
+        name={[udpFieldName, 'settings', 'mode']}
+        getValueProps={(value) => ({ value: udpHopModesFromWire(value) })}
+        normalize={(value: string[]) => value.join(',')}
+        rules={[{ required: true, message: 'pick at least one mode' }]}
+      >
+        <Select mode="multiple" options={UDP_HOP_MODES} />
+      </Form.Item>
+      <Form.Item
+        label="Interval (s)"
+        name={[udpFieldName, 'settings', 'interval']}
+        rules={[{ validator: validateUdpHopInterval }]}
+      >
+        <Input placeholder="30 or 5-10" />
+      </Form.Item>
+      <Form.Item label="Remote Ports" name={[udpFieldName, 'settings', 'remotePorts']}>
+        <Input placeholder="20000-50000" />
+      </Form.Item>
+      <Form.Item label="Remote IPs" name={[udpFieldName, 'settings', 'remoteIPs']}>
+        <Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',', ' ']} />
+      </Form.Item>
+    </>
+  );
+}
+
 function NoiseItems({
   udpFieldName,
   form,
@@ -1541,7 +1639,15 @@ function ItemEditor({
   );
 }
 
-function QuicParamsForm({ base, form }: { base: (string | number)[]; form: FormInstance }) {
+function QuicParamsForm({
+  base,
+  form,
+  showUdpHop,
+}: {
+  base: (string | number)[];
+  form: FormInstance;
+  showUdpHop: boolean;
+}) {
   const congestion = Form.useWatch([...base, 'congestion'], form) as string | undefined;
   const udpHop = Form.useWatch([...base, 'udpHop'], { form, preserve: true }) as
     | Record<string, unknown>
@@ -1595,22 +1701,31 @@ function QuicParamsForm({ base, form }: { base: (string | number)[]; form: FormI
         </>
       )}
 
-      <Form.Item label="UDP Hop">
-        <Switch
-          checked={hasUdpHop}
-          onChange={(v) => {
-            form.setFieldValue([...base, 'udpHop'], v ? defaultUdpHop() : undefined);
-          }}
-        />
-      </Form.Item>
-      {hasUdpHop && (
+      {/* Advertised to clients (link mport, JSON-subscription udphop mask); the server
+          itself listens on its own port. */}
+      {showUdpHop && (
         <>
-          <Form.Item label="Hop Ports" name={[...base, 'udpHop', 'ports']}>
-            <Input placeholder="e.g. 20000-50000" />
-          </Form.Item>
-          <Form.Item label="Hop Interval (s)" name={[...base, 'udpHop', 'interval']}>
-            <Input placeholder="e.g. 5-10" />
+          <Form.Item
+            label="UDP Hop"
+            tooltip="Advertised to clients as a hop range; the server listens on its own port only"
+          >
+            <Switch
+              checked={hasUdpHop}
+              onChange={(v) => {
+                form.setFieldValue([...base, 'udpHop'], v ? defaultUdpHop() : undefined);
+              }}
+            />
           </Form.Item>
+          {hasUdpHop && (
+            <>
+              <Form.Item label="Hop Ports" name={[...base, 'udpHop', 'ports']}>
+                <Input placeholder="e.g. 20000-50000" />
+              </Form.Item>
+              <Form.Item label="Hop Interval (s)" name={[...base, 'udpHop', 'interval']}>
+                <Input placeholder="e.g. 5-10" />
+              </Form.Item>
+            </>
+          )}
         </>
       )}
 

+ 148 - 0
frontend/src/lib/xray/forms/transport/XDriveForm.tsx

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

+ 12 - 1
frontend/src/lib/xray/inbound-defaults.ts

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

+ 5 - 0
frontend/src/lib/xray/inbound-form-adapter.ts

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

+ 7 - 0
frontend/src/lib/xray/inbound-link.ts

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

+ 13 - 0
frontend/src/lib/xray/inbound-tag.ts

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

+ 11 - 1
frontend/src/lib/xray/inbound-tls-defaults.ts

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

+ 1 - 0
frontend/src/lib/xray/node-protocols.ts

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

+ 8 - 0
frontend/src/lib/xray/outbound-defaults.ts

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

+ 25 - 1
frontend/src/lib/xray/outbound-form-adapter.ts

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

+ 4 - 3
frontend/src/lib/xray/outbound-link-parser.ts

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

+ 2 - 1
frontend/src/lib/xray/protocol-capabilities.ts

@@ -17,6 +17,7 @@ const STREAM_PROTOCOLS = [
   'hysteria',
   'wireguard',
   'tunnel',
+  'masque',
 ];
 const VISION_FLOW = 'xtls-rprx-vision';
 const SS_2022_PREFIX = '2022';
@@ -37,7 +38,7 @@ export interface CapabilityShadowsocksSlice extends CapabilityProtocolSlice {
 }
 
 export function canEnableTls(values: CapabilityProtocolSlice): boolean {
-  if (values.protocol === 'hysteria') return true;
+  if (values.protocol === 'hysteria' || values.protocol === 'masque') return true;
   if (!TLS_ELIGIBLE_PROTOCOLS.includes(values.protocol)) return false;
   return TLS_NETWORKS.includes(values.streamSettings?.network ?? '');
 }

+ 4 - 0
frontend/src/lib/xray/stream-defaults.ts

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

+ 29 - 0
frontend/src/lib/xray/udphop-mask.ts

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

+ 49 - 18
frontend/src/lib/xray/xdns-mask.ts

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

+ 1 - 1
frontend/src/pages/api-docs/endpoints.ts

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

+ 1 - 0
frontend/src/pages/clients/BulkAttachInboundsModal.tsx

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

+ 1 - 0
frontend/src/pages/clients/BulkDetachInboundsModal.tsx

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

+ 1 - 0
frontend/src/pages/clients/ClientBulkAddModal.tsx

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

+ 1 - 0
frontend/src/pages/clients/ClientFormModal.tsx

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

+ 1 - 0
frontend/src/pages/hosts/HostList.tsx

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

+ 1 - 0
frontend/src/pages/inbounds/InboundsPage.tsx

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

+ 46 - 3
frontend/src/pages/inbounds/form/InboundFormModal.tsx

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

+ 1 - 0
frontend/src/pages/inbounds/form/protocols/index.ts

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

+ 35 - 0
frontend/src/pages/inbounds/form/protocols/masque.tsx

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

+ 2 - 1
frontend/src/pages/inbounds/form/useSecurityActions.ts

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

+ 1 - 0
frontend/src/pages/inbounds/list/helpers.ts

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

+ 1 - 0
frontend/src/pages/inbounds/useInbounds.ts

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

+ 1 - 0
frontend/src/pages/settings/SubBalancerFormModal.tsx

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

+ 5 - 0
frontend/src/pages/xray/dns/DnsServerModal.tsx

@@ -32,6 +32,7 @@ interface DnsServerModalProps {
 const STRATEGIES = DnsQueryStrategySchema.options;
 
 type DnsServerForm = {
+  id: string;
   address: string;
   port: number;
   domains: string[];
@@ -50,6 +51,7 @@ type DnsServerForm = {
 
 function defaultFormValues(): DnsServerForm {
   return {
+    id: '',
     address: 'localhost',
     port: 53,
     domains: [],
@@ -83,6 +85,7 @@ function valuesFromServer(server: DnsServerValue | null): DnsServerForm {
     skipFallback: data?.skipFallback ?? server.skipFallback ?? false,
     disableCache: data?.disableCache ?? server.disableCache ?? false,
     finalQuery: data?.finalQuery ?? server.finalQuery ?? false,
+    id: data?.id ?? server.id ?? '',
     tag: data?.tag ?? server.tag ?? '',
     clientIP: data?.clientIP ?? server.clientIP ?? '',
     serveStale: data?.serveStale ?? server.serveStale ?? false,
@@ -101,6 +104,7 @@ function valuesToWire(values: DnsServerForm): DnsServerValue {
     values.skipFallback === false &&
     values.disableCache === false &&
     values.finalQuery === false &&
+    !values.id &&
     !values.tag &&
     !values.clientIP &&
     values.serveStale === false &&
@@ -122,6 +126,7 @@ function valuesToWire(values: DnsServerForm): DnsServerValue {
     timeoutMs: values.timeoutMs,
   };
   if (!isEncryptedDnsAddress(values.address)) out.port = values.port;
+  if (values.id) out.id = values.id;
   if (values.tag) out.tag = values.tag;
   if (values.clientIP) out.clientIP = values.clientIP;
   return out as DnsServerValue;

+ 28 - 3
frontend/src/pages/xray/outbounds/OutboundFormModal.tsx

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

+ 5 - 0
frontend/src/pages/xray/outbounds/outbound-form-constants.ts

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

+ 13 - 0
frontend/src/pages/xray/outbounds/outbound-form-helpers.ts

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

+ 2 - 1
frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts

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

+ 1 - 0
frontend/src/pages/xray/outbounds/protocols/index.ts

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

+ 18 - 0
frontend/src/pages/xray/outbounds/protocols/masque.tsx

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

+ 8 - 1
frontend/src/pages/xray/outbounds/security/tls.tsx

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

+ 1 - 0
frontend/src/pages/xray/outbounds/transport/index.ts

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

+ 92 - 0
frontend/src/pages/xray/outbounds/transport/masque.tsx

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

+ 52 - 0
frontend/src/pages/xray/overrides/WarpModal.tsx

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

+ 2 - 0
frontend/src/schemas/dns.ts

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

+ 8 - 0
frontend/src/schemas/forms/outbound-form.ts

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

+ 1 - 0
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',

+ 2 - 0
frontend/src/schemas/primitives/protocol.ts

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

+ 3 - 0
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<typeof InboundSettingsSchema>;

+ 33 - 0
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<typeof MasqueClientSchema>;
+
+export const MASQUE_DEFAULT_ADDRESS_POOL = ['10.14.0.1/24', 'fd14::1/64'];
+
+export const MasqueInboundSettingsSchema = z.object({
+  clients: z.array(MasqueClientSchema).default([]),
+  // Tunnel addresses are leased from these prefixes: at most one IPv4 and one IPv6.
+  address: z.array(z.string()).default(MASQUE_DEFAULT_ADDRESS_POOL),
+  mtu: z.number().int().min(1280).max(65535).optional(),
+});
+export type MasqueInboundSettings = z.infer<typeof MasqueInboundSettingsSchema>;

+ 3 - 0
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 }),

+ 12 - 0
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<typeof MasqueOutboundSettingsSchema>;

+ 17 - 0
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<typeof NetworkSchema>;
 
@@ -53,6 +61,15 @@ const TransportNetworkSettingsSchema = z.discriminatedUnion('network', [
   }),
   z.object({ network: z.literal('xhttp'), xhttpSettings: XHttpStreamSettingsSchema }),
   z.object({ network: z.literal('hysteria'), hysteriaSettings: HysteriaStreamSettingsSchema }),
+  // `masque` pairs only with the masque protocol, in both directions.
+  z.object({ network: z.literal('masque'), masqueSettings: MasqueStreamSettingsSchema }),
+  // The stream-level address/port is the domain front XDRIVE dials for its storage API.
+  z.object({
+    network: z.literal('xdrive'),
+    xdriveSettings: XDriveStreamSettingsSchema,
+    address: z.string().optional(),
+    port: PortSchema.optional(),
+  }),
 ]);
 
 // Wireguard (always a UDP listener) and Tunnel (dokodemo-door) expose no

+ 26 - 0
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<typeof MasqueWarpSchema>;
+
+// A server reads only `path`; host, user/pass (Basic auth), headers and warp are the
+// client side. Every key is optional so a stored stream round-trips byte-stably.
+export const MasqueStreamSettingsSchema = z.object({
+  host: z.string().optional(),
+  path: z.string().optional(),
+  user: z.string().optional(),
+  pass: z.string().optional(),
+  headers: z.record(z.string(), z.string()).optional(),
+  warp: MasqueWarpSchema.optional(),
+});
+export type MasqueStreamSettings = z.infer<typeof MasqueStreamSettingsSchema>;

+ 26 - 0
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<typeof XDriveServiceSchema>;
+
+const MillisSchema = z.number().int().min(0);
+
+export const XDriveStreamSettingsSchema = z.object({
+  service: XDriveServiceSchema.default('Google Drive'),
+  remoteFolder: z.string().default(''),
+  // Google Drive takes exactly three, in order: ClientID, ClientSecret, RefreshToken.
+  secrets: z.array(z.string()).default([]),
+  segmentBytes: z.number().int().min(0).optional(),
+  flushIntervalMs: MillisSchema.optional(),
+  pollIntervalMs: MillisSchema.optional(),
+  maxPollIntervalMs: MillisSchema.optional(),
+  sessionTtlSeconds: z.number().int().min(0).optional(),
+  concurrency: z.number().int().min(0).optional(),
+  eagerWindowMs: MillisSchema.optional(),
+  holeTimeoutMs: MillisSchema.optional(),
+  template: z.record(z.string(), z.unknown()).optional(),
+});
+export type XDriveStreamSettings = z.infer<typeof XDriveStreamSettingsSchema>;

+ 9 - 6
frontend/src/test/__snapshots__/finalmask.test.ts.snap

@@ -274,14 +274,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 +293,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",
+            ],
           },
         ],
       },

+ 89 - 0
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": "[email protected]",
+        "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,

+ 2 - 0
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://[email protected]: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%[email protected]:8388?type=tcp#parity-test"`;
 
 exports[`genInboundLinks orchestrator > trojan-ws-tls: byte-stable 1`] = `"trojan://[email protected]:443?type=ws&path=%2Ftrojan&host=trojan.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=trojan.example.test#parity-test"`;

+ 25 - 0
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",

+ 2 - 2
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.',
       );
     });
   });

+ 30 - 0
frontend/src/test/dns-server-modal.test.tsx

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

+ 3 - 3
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"] }]
       }
     },
     {

+ 70 - 0
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": "[email protected]",
+        "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": ""
+      }
+    }
+  }
+}

+ 22 - 0
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
+  }
+}

+ 21 - 1
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']);
+  });
+});

+ 149 - 0
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,138 @@ describe('InboundFormModal', () => {
     });
   });
 
+  // The core reads an XDRIVE inbound's TLS/REALITY as the storage API's client TLS, so
+  // switching a REALITY inbound to XDRIVE must store security none with its secrets.
+  it('switches a REALITY inbound to an XDRIVE stream with security none', async () => {
+    const post = vi.mocked(HttpUtil.post);
+    post.mockClear();
+    renderCloneLikeEdit(cloneLikeVlessInbound('example.com:443'));
+
+    fireEvent.click(screen.getByRole('tab', { name: 'Stream' }));
+    const transmission = Array.from(document.querySelectorAll('.ant-form-item')).find(
+      (el) =>
+        el.querySelector('.ant-form-item-label label')?.textContent?.trim() === 'Transmission',
+    );
+    const select = transmission?.querySelector('.ant-select') as HTMLElement;
+    fireEvent.mouseDown(select.querySelector('.ant-select-selector') ?? select);
+    const option = Array.from(document.querySelectorAll('.ant-select-item-option')).find(
+      (o) => (o.getAttribute('title') ?? o.textContent ?? '').trim() === 'XDRIVE',
+    ) as HTMLElement;
+    fireEvent.click(option);
+    for (const [label, value] of [
+      ['Remote folder', 'xray-tunnel'],
+      ['Client ID', 'id.apps.googleusercontent.com'],
+      ['Client secret', 'client-secret'],
+      ['Refresh token', 'refresh-token'],
+    ]) {
+      fireEvent.change(await screen.findByLabelText(label), { target: { value } });
+    }
+    fireEvent.click(primaryButton());
+
+    const isUpdate = ([url]: unknown[]) => url === '/panel/api/inbounds/update/42';
+    await waitFor(() => expect(post.mock.calls.some(isUpdate)).toBe(true));
+    const payload = post.mock.calls.find(isUpdate)![1] as { streamSettings: string };
+    const stream = JSON.parse(payload.streamSettings) as Record<string, unknown>;
+    expect(stream).toMatchObject({
+      network: 'xdrive',
+      security: 'none',
+      xdriveSettings: {
+        service: 'Google Drive',
+        remoteFolder: 'xray-tunnel',
+        secrets: ['id.apps.googleusercontent.com', 'client-secret', 'refresh-token'],
+      },
+    });
+    expect(stream.realitySettings).toBeUndefined();
+  });
+
+  // xray-core runs MASQUE only on its own transport behind TLS, and leases tunnel
+  // addresses from settings.address; picking the protocol must seed all three.
+  it('adds a MASQUE inbound on its own transport with TLS and an address pool', async () => {
+    const post = vi.mocked(HttpUtil.post);
+    post.mockClear();
+    renderModal();
+
+    chooseVirtualizedProtocol('masque');
+    await act(async () => {
+      await new Promise((r) => setTimeout(r, 0));
+    });
+    fireEvent.click(screen.getByRole('tab', { name: 'Security' }));
+    expect(screen.queryByRole('radio', { name: 'None' })).toBeNull();
+    fireEvent.change(await screen.findByLabelText('Public Key'), {
+      target: { value: '/etc/ssl/certs/m.crt' },
+    });
+    fireEvent.change(screen.getByLabelText('Private Key'), {
+      target: { value: '/etc/ssl/private/m.key' },
+    });
+    fireEvent.click(primaryButton());
+
+    const isAdd = ([url]: unknown[]) => url === '/panel/api/inbounds/add';
+    await waitFor(() => expect(post.mock.calls.some(isAdd)).toBe(true));
+    const payload = post.mock.calls.find(isAdd)![1] as {
+      protocol: string;
+      settings: string;
+      streamSettings: string;
+    };
+    expect(payload.protocol).toBe('masque');
+    expect(JSON.parse(payload.settings)).toMatchObject({ address: ['10.14.0.1/24', 'fd14::1/64'] });
+    expect(JSON.parse(payload.streamSettings)).toMatchObject({
+      network: 'masque',
+      security: 'tls',
+      masqueSettings: { path: '/.well-known/masque/ip/*/*/' },
+      tlsSettings: { alpn: ['h3'] },
+    });
+  });
+
+  // xray-core refuses a udphop mask on a listener ("client only"), so an inbound keeps its
+  // advertised hop range in quicParams.udpHop: the client-side lift must not run here.
+  it('keeps an inbound hop range in quicParams.udpHop on save', async () => {
+    const post = vi.mocked(HttpUtil.post);
+    post.mockClear();
+    const hysteria = new DBInbound({
+      id: 43,
+      port: 443,
+      listen: '',
+      protocol: 'hysteria',
+      remark: 'hy',
+      enable: true,
+      settings: { version: 2, clients: [] },
+      streamSettings: {
+        network: 'hysteria',
+        security: 'tls',
+        hysteriaSettings: { version: 2, auth: '', udpIdleTimeout: 60 },
+        tlsSettings: {
+          serverName: 'hy.example.com',
+          alpn: ['h3'],
+          certificates: [
+            { certificateFile: '/etc/ssl/certs/hy.crt', keyFile: '/etc/ssl/private/hy.key' },
+          ],
+        },
+        finalmask: { quicParams: { udpHop: { ports: '20000-30000', interval: '5-10' } } },
+      },
+      sniffing: { enabled: false },
+      nodeId: null,
+      shareAddrStrategy: 'listen',
+      shareAddr: '',
+    });
+    renderCloneLikeEdit(hysteria);
+    // Form watches flush in a macrotask; save only once the hop fields have emitted.
+    await screen.findByDisplayValue('20000-30000');
+    await act(async () => {
+      await new Promise((r) => setTimeout(r, 0));
+    });
+
+    fireEvent.click(primaryButton());
+
+    const isUpdate = ([url]: unknown[]) => url === '/panel/api/inbounds/update/43';
+    await waitFor(() => expect(post.mock.calls.some(isUpdate)).toBe(true));
+    const payload = post.mock.calls.find(isUpdate)![1] as { streamSettings: string };
+    const finalmask = JSON.parse(payload.streamSettings).finalmask as Record<string, unknown>;
+    expect(finalmask.quicParams).toMatchObject({
+      udpHop: { ports: '20000-30000', interval: '5-10' },
+    });
+    expect(JSON.stringify(finalmask.udp ?? [])).not.toContain('udphop');
+  });
+
   // 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 () => {

+ 22 - 0
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', () => {

+ 21 - 0
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' } }),

+ 306 - 13
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<string, unknown> | 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,279 @@ 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) => (
+      <QueryClientProvider client={queryClient}>
+        <ThemeProvider>
+          <OutboundFormModal
+            open={open}
+            outbound={outbound}
+            existingTags={[]}
+            onClose={() => {}}
+            onConfirm={onConfirm}
+          />
+        </ThemeProvider>
+      </QueryClientProvider>
+    );
+    const { rerender } = render(tree(false));
+    rerender(tree(true));
+    await act(async () => {
+      await new Promise((r) => setTimeout(r, 0));
+    });
+
+    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) => (
+      <QueryClientProvider client={queryClient}>
+        <ThemeProvider>
+          <OutboundFormModal
+            open={open}
+            outbound={outbound}
+            existingTags={[]}
+            onClose={() => {}}
+            onConfirm={onConfirm}
+          />
+        </ThemeProvider>
+      </QueryClientProvider>
+    );
+    const { rerender } = render(tree(false));
+    rerender(tree(true));
+    await act(async () => {
+      await new Promise((r) => setTimeout(r, 0));
+    });
+
+    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<string, unknown> };
+    expect(payload.streamSettings).toMatchObject({
+      network: 'xdrive',
+      xdriveSettings: {
+        service: 'Google Drive',
+        remoteFolder: 'xray-tunnel',
+        secrets: ['id.apps.googleusercontent.com', 'client-secret', 'refresh-token'],
+      },
+    });
+  });
+});
+
+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) => (
+      <QueryClientProvider client={queryClient}>
+        <ThemeProvider>
+          <OutboundFormModal
+            open={open}
+            outbound={outbound}
+            existingTags={[]}
+            onClose={() => {}}
+            onConfirm={onConfirm}
+          />
+        </ThemeProvider>
+      </QueryClientProvider>
+    );
+    const { rerender } = render(tree(false));
+    rerender(tree(true));
+    await act(async () => {
+      await new Promise((r) => setTimeout(r, 0));
+    });
+
+    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<string, unknown>;
+      streamSettings: {
+        network: string;
+        security: string;
+        masqueSettings: Record<string, unknown>;
+      };
+    };
+    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) => (
+      <QueryClientProvider client={queryClient}>
+        <ThemeProvider>
+          <OutboundFormModal
+            open={open}
+            outbound={outbound}
+            existingTags={[]}
+            onClose={() => {}}
+            onConfirm={onConfirm}
+          />
+        </ThemeProvider>
+      </QueryClientProvider>
+    );
+    const { rerender } = render(tree(false));
+    rerender(tree(true));
+    await act(async () => {
+      await new Promise((r) => setTimeout(r, 0));
+    });
+
+    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<string, unknown> } };
+    };
+    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');
   });
 });

+ 42 - 12
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://[email protected]:443?sni=hy.example.com&fm=${fm}#hy`,
+    );
+    expect((out!.streamSettings as Record<string, unknown>).finalmask).toEqual({
+      udp: [
+        {
+          type: 'udphop',
+          settings: { mode: 'intervalRemote', remotePorts: '20000-30000', interval: '5-10' },
+        },
+      ],
+    });
+  });
+
   it('parses a hysteria2:// link with sni', () => {
     const link = 'hysteria2://[email protected]: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<string, unknown>;
     expect(quicParams.congestion).toBe('bbr');
     expect(quicParams.maxIdleTimeout).toBe(30);
-    expect((quicParams.udpHop as Record<string, unknown>).interval).toBe('5-10');
-    expect((quicParams.udpHop as Record<string, unknown>).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', () => {

+ 26 - 0
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 }) => (
+      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
+    );
+    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<Record<string, unknown>> };
+    };
+    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) => {

+ 100 - 0
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(
+      <WarpModal
+        open
+        templateSettings={{ outbounds: [{ tag: 'direct' }] }}
+        onClose={() => {}}
+        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(
+      <WarpModal
+        open
+        templateSettings={{ outbounds: [{ tag: 'direct' }, { tag: 'warp-masque' }] }}
+        onClose={() => {}}
+        onAddOutbound={onAddOutbound}
+        onResetOutbound={onResetOutbound}
+        onRemoveOutbound={() => {}}
+      />,
+    );
+
+    await clickAddMasque();
+
+    await waitFor(() =>
+      expect(onResetOutbound).toHaveBeenCalledWith({ index: 1, outbound: expected }),
+    );
+    expect(onAddOutbound).not.toHaveBeenCalled();
+  });
+});

+ 18 - 15
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
 )

+ 41 - 30
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=

+ 81 - 15
internal/database/db.go

@@ -1282,7 +1282,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
@@ -1423,8 +1423,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
 		}
 	}
@@ -1928,16 +1934,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
@@ -1949,7 +1955,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
@@ -1961,7 +1967,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
@@ -1984,20 +1990,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: "XdnsFinalmaskNamesAddrsFix"}).Error
+	})
+}
+
+// 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: "XdnsFinalmaskObjectsFix"}).Error
+		return tx.Create(&model.HistoryOfSeeders{SeederName: "UDPHopClientMaskFix"}).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) {
+// 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
 	}
@@ -2007,7 +2068,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
 		}
 	}
@@ -2044,6 +2105,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"])...)

+ 46 - 1
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

+ 117 - 0
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)
+	}
+}

+ 12 - 10
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)
 	}

+ 32 - 0
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,

Некоторые файлы не были показаны из-за большого количества измененных файлов