Преглед изворни кода

feat(xray): add the MASQUE protocol and transport

xray-core v26.10.10 serves MASQUE: CONNECT-IP over HTTP/3 or HTTP/2,
always behind TLS, as an inbound and an outbound protocol riding a
dedicated "masque" transport. The panel could not create either.

Inbound: MASQUE is a multi-user protocol whose users log in with their
email and password over Basic auth. The panel keeps the client's
"password" like trojan and GenXrayInboundConfig hands it to the core as
"pass", so the full-config path, the live AddInbound path and node
pushes all emit what the core reads; one user with an empty pass makes
the core refuse the whole inbound. Live user adds build a masque.Account
from either key, client-only changes diff per user so a new client does
not drop every connected tunnel, and client creation, copy, validation
and edits key on the password. The address pool and MTU get form
fields, TLS is mandatory, and the port check follows the core's
listener choice: HTTP/2 on TCP when the ALPN offers h2, HTTP/3 on UDP
when it offers h3 or not h2. Nodes may host MASQUE from v3.9.1, the
first panel release that knows to rename the password.

MASQUE has no share-link format, so links and Clash skip it; the JSON
subscription builds the client's masque outbound with its email and
password, and the subscription allowlist now lets a subId see MASQUE
inbounds at all. The IP-limit job, client pages and traffic tracking
list the protocol.

Outbound: the form edits the server, remote DNS, and the transport's
host, path, Basic credentials, headers and WARP block (enrolled key,
endpoint public key, tunnel addresses), which xray-core refuses next to
user/pass. The outbound test probes it through the core, since its
default HTTP/3 dial is UDP.
MHSanaei пре 13 часа
родитељ
комит
b04039c2bd
96 измењених фајлова са 1506 додато и 51 уклоњено
  1. 1 0
      docs/content/docs/en/config/inbounds.mdx
  2. 54 0
      docs/content/docs/en/config/masque.mdx
  3. 1 0
      docs/content/docs/en/config/meta.json
  4. 2 1
      docs/content/docs/en/config/transports.mdx
  5. 1 0
      docs/content/docs/fa/config/inbounds.mdx
  6. 55 0
      docs/content/docs/fa/config/masque.mdx
  7. 1 0
      docs/content/docs/fa/config/meta.json
  8. 2 1
      docs/content/docs/fa/config/transports.mdx
  9. 1 0
      docs/content/docs/ru/config/inbounds.mdx
  10. 54 0
      docs/content/docs/ru/config/masque.mdx
  11. 1 0
      docs/content/docs/ru/config/meta.json
  12. 2 1
      docs/content/docs/ru/config/transports.mdx
  13. 1 0
      docs/content/docs/zh/config/inbounds.mdx
  14. 51 0
      docs/content/docs/zh/config/masque.mdx
  15. 1 0
      docs/content/docs/zh/config/meta.json
  16. 2 1
      docs/content/docs/zh/config/transports.mdx
  17. 1 0
      frontend/src/components/command-palette/CommandPalette.tsx
  18. 12 1
      frontend/src/lib/xray/inbound-defaults.ts
  19. 5 0
      frontend/src/lib/xray/inbound-form-adapter.ts
  20. 13 0
      frontend/src/lib/xray/inbound-tag.ts
  21. 5 0
      frontend/src/lib/xray/inbound-tls-defaults.ts
  22. 1 0
      frontend/src/lib/xray/node-protocols.ts
  23. 8 0
      frontend/src/lib/xray/outbound-defaults.ts
  24. 25 1
      frontend/src/lib/xray/outbound-form-adapter.ts
  25. 2 1
      frontend/src/lib/xray/protocol-capabilities.ts
  26. 1 0
      frontend/src/pages/clients/BulkAttachInboundsModal.tsx
  27. 1 0
      frontend/src/pages/clients/BulkDetachInboundsModal.tsx
  28. 1 0
      frontend/src/pages/clients/ClientBulkAddModal.tsx
  29. 1 0
      frontend/src/pages/clients/ClientFormModal.tsx
  30. 1 0
      frontend/src/pages/hosts/HostList.tsx
  31. 1 0
      frontend/src/pages/inbounds/InboundsPage.tsx
  32. 22 3
      frontend/src/pages/inbounds/form/InboundFormModal.tsx
  33. 1 0
      frontend/src/pages/inbounds/form/protocols/index.ts
  34. 35 0
      frontend/src/pages/inbounds/form/protocols/masque.tsx
  35. 1 0
      frontend/src/pages/inbounds/list/helpers.ts
  36. 1 0
      frontend/src/pages/inbounds/useInbounds.ts
  37. 1 0
      frontend/src/pages/settings/SubBalancerFormModal.tsx
  38. 25 3
      frontend/src/pages/xray/outbounds/OutboundFormModal.tsx
  39. 4 0
      frontend/src/pages/xray/outbounds/outbound-form-constants.ts
  40. 10 0
      frontend/src/pages/xray/outbounds/outbound-form-helpers.ts
  41. 2 1
      frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts
  42. 1 0
      frontend/src/pages/xray/outbounds/protocols/index.ts
  43. 18 0
      frontend/src/pages/xray/outbounds/protocols/masque.tsx
  44. 1 0
      frontend/src/pages/xray/outbounds/transport/index.ts
  45. 92 0
      frontend/src/pages/xray/outbounds/transport/masque.tsx
  46. 8 0
      frontend/src/schemas/forms/outbound-form.ts
  47. 1 0
      frontend/src/schemas/primitives/outbound-protocol.ts
  48. 2 0
      frontend/src/schemas/primitives/protocol.ts
  49. 3 0
      frontend/src/schemas/protocols/inbound/index.ts
  50. 33 0
      frontend/src/schemas/protocols/inbound/masque.ts
  51. 3 0
      frontend/src/schemas/protocols/outbound/index.ts
  52. 12 0
      frontend/src/schemas/protocols/outbound/masque.ts
  53. 5 0
      frontend/src/schemas/protocols/stream/index.ts
  54. 26 0
      frontend/src/schemas/protocols/stream/masque.ts
  55. 89 0
      frontend/src/test/__snapshots__/inbound-full.test.ts.snap
  56. 2 0
      frontend/src/test/__snapshots__/inbound-link.test.ts.snap
  57. 2 2
      frontend/src/test/client-form-modal.test.tsx
  58. 70 0
      frontend/src/test/golden/fixtures/inbound-full/masque-tls.json
  59. 55 0
      frontend/src/test/inbound-form-modal.test.tsx
  60. 21 0
      frontend/src/test/inbound-tag.test.ts
  61. 74 0
      frontend/src/test/outbound-form-modal.test.tsx
  62. 45 0
      internal/database/model/model.go
  63. 28 0
      internal/sub/json_service.go
  64. 83 0
      internal/sub/masque_test.go
  65. 1 1
      internal/sub/service.go
  66. 1 1
      internal/web/job/check_client_ip_job.go
  67. 1 1
      internal/web/service/client_crud.go
  68. 2 2
      internal/web/service/client_inbound_apply.go
  69. 6 1
      internal/web/service/inbound.go
  70. 1 1
      internal/web/service/inbound_clients.go
  71. 101 0
      internal/web/service/inbound_masque_test.go
  72. 2 0
      internal/web/service/inbound_protocol.go
  73. 41 0
      internal/web/service/inbound_protocol_masque_test.go
  74. 14 1
      internal/web/service/outbound/outbound.go
  75. 12 0
      internal/web/service/outbound/outbound_endpoints_test.go
  76. 3 0
      internal/web/service/outbound/probe_protocol_case_test.go
  77. 23 0
      internal/web/service/port_conflict.go
  78. 7 0
      internal/web/service/port_conflict_test.go
  79. 4 0
      internal/web/service/xray.go
  80. 9 2
      internal/web/translation/ar-EG.json
  81. 9 2
      internal/web/translation/en-US.json
  82. 9 2
      internal/web/translation/es-ES.json
  83. 9 2
      internal/web/translation/fa-IR.json
  84. 9 2
      internal/web/translation/id-ID.json
  85. 9 2
      internal/web/translation/ja-JP.json
  86. 9 2
      internal/web/translation/pt-BR.json
  87. 9 2
      internal/web/translation/ru-RU.json
  88. 9 2
      internal/web/translation/tr-TR.json
  89. 9 2
      internal/web/translation/uk-UA.json
  90. 9 2
      internal/web/translation/vi-VN.json
  91. 9 2
      internal/web/translation/zh-CN.json
  92. 9 2
      internal/web/translation/zh-TW.json
  93. 19 0
      internal/xray/api.go
  94. 43 0
      internal/xray/api_masque_test.go
  95. 1 1
      internal/xray/hot_diff.go
  96. 23 0
      internal/xray/hot_diff_test.go

+ 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

+ 54 - 0
docs/content/docs/en/config/masque.mdx

@@ -0,0 +1,54 @@
+---
+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
+
+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",

+ 2 - 1
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, XDRIVE — 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
 ---
 
@@ -24,6 +24,7 @@ network writes its own settings key on the wire (`tcpSettings`, `kcpSettings`, 
 | **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

+ 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` است که

+ 55 - 0
docs/content/docs/fa/config/masque.mdx

@@ -0,0 +1,55 @@
+---
+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
+
+**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",

+ 2 - 1
docs/content/docs/fa/config/transports.mdx

@@ -1,6 +1,6 @@
 ---
 title: انتقال‌ها و امنیت
-description: هر انتقالی که 3x-ui ارائه می‌دهد — TCP، mKCP، WebSocket، gRPC، HTTPUpgrade، XHTTP، Hysteria، XDRIVE — به‌همراه تنظیماتشان، و نیز مبهم‌سازی 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
 ---
 
@@ -24,6 +24,7 @@ icon: Network
 | **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) هیچ انتخابگر انتقالی

+ 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`

+ 54 - 0
docs/content/docs/ru/config/masque.mdx

@@ -0,0 +1,54 @@
+---
+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
+
+Включите **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",

+ 2 - 1
docs/content/docs/ru/config/transports.mdx

@@ -1,6 +1,6 @@
 ---
 title: Транспорты и безопасность
-description: Все транспорты, которые предоставляет 3x-ui — TCP, mKCP, WebSocket, gRPC, HTTPUpgrade, XHTTP, Hysteria, XDRIVE — с их настройками, а также обфускация 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
 ---
 
@@ -26,6 +26,7 @@ network записывает свой собственный ключ настр
 | **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) не предоставляют

+ 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`

+ 51 - 0
docs/content/docs/zh/config/masque.mdx

@@ -0,0 +1,51 @@
+---
+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
+
+在 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",

+ 2 - 1
docs/content/docs/zh/config/transports.mdx

@@ -1,6 +1,6 @@
 ---
 title: 传输方式与安全层
-description: 3x-ui 提供的每一种传输方式——TCP、mKCP、WebSocket、gRPC、HTTPUpgrade、XHTTP、Hysteria、XDRIVE——及其设置项,外加 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,7 @@ icon: Network
 | **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)入站不提供传输方式选择器——它们的传输流

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

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

+ 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;
   }

+ 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';

+ 5 - 0
frontend/src/lib/xray/inbound-tls-defaults.ts

@@ -31,6 +31,11 @@ export function createTlsSettingsWithDefaultCert(network?: string): Record<strin
   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;

+ 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 ?? '');
 }

+ 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;

+ 22 - 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';
@@ -66,6 +70,8 @@ import {
   AmneziawgFields,
   HttpFields,
   HysteriaFields,
+  MasqueSettingsFields,
+  MasqueStreamFields,
   MixedFields,
   MtprotoFields,
   ShadowsocksFields,
@@ -285,6 +291,7 @@ export default function InboundFormModal({
    */
   const hasSelectableTransport =
     protocol !== Protocols.HYSTERIA &&
+    protocol !== Protocols.MASQUE &&
     protocol !== Protocols.WIREGUARD &&
     protocol !== Protocols.TUNNEL &&
     protocol !== Protocols.TUIC;
@@ -552,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: {} });
         }
       }
@@ -819,6 +833,8 @@ export default function InboundFormModal({
 
       {protocol === Protocols.TUIC && <TuicFields />}
 
+      {protocol === Protocols.MASQUE && <MasqueSettingsFields />}
+
       {protocol === Protocols.TUN && <TunFields />}
 
       {protocol === Protocols.TUNNEL && <TunnelFields />}
@@ -943,6 +959,8 @@ export default function InboundFormModal({
           dropdown is hidden above. */}
       {protocol === Protocols.HYSTERIA && <HysteriaFields />}
 
+      {protocol === Protocols.MASQUE && <MasqueStreamFields />}
+
       {hasSelectableTransport && (
         <>
           {network === 'tcp' && <RawForm />}
@@ -989,7 +1007,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 = (
     <>
@@ -1177,6 +1195,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>
+  );
+}

+ 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> = {

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

@@ -21,6 +21,7 @@ import {
 import {
   FLOW_OPTIONS,
   HYSTERIA_NETWORK_OPTION,
+  MASQUE_NETWORK_OPTION,
   NETWORK_OPTIONS,
   PROTOCOL_OPTIONS,
   SERVER_PROTOCOLS,
@@ -30,6 +31,7 @@ import {
   applyNetworkChange,
   buildAddModeValues,
   hysteriaStreamSlice,
+  masqueStreamSlice,
   newStreamSlice,
 } from './outbound-form-helpers';
 import {
@@ -38,6 +40,7 @@ import {
   FreedomFields,
   HttpFields,
   LoopbackFields,
+  MasqueFields,
   ServerTarget,
   ShadowsocksFields,
   SocksFields,
@@ -52,6 +55,7 @@ import {
   HttpUpgradeForm,
   HysteriaForm,
   KcpForm,
+  MasqueForm,
   MuxForm,
   RawForm,
   SockoptForm,
@@ -162,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;
@@ -194,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',
@@ -437,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 />}
@@ -470,7 +488,9 @@ export default function OutboundFormModal({
                               options={
                                 protocol === 'hysteria'
                                   ? [HYSTERIA_NETWORK_OPTION]
-                                  : NETWORK_OPTIONS
+                                  : protocol === 'masque'
+                                    ? [MASQUE_NETWORK_OPTION]
+                                    : NETWORK_OPTIONS
                               }
                             />
                           </Form.Item>
@@ -490,6 +510,8 @@ export default function OutboundFormModal({
                           {network === 'hysteria' && <HysteriaForm />}
 
                           {network === 'xdrive' && <XDriveForm />}
+
+                          {network === 'masque' && <MasqueForm />}
                         </>
                       )}
 
@@ -532,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>}

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

@@ -51,6 +51,9 @@ export const NETWORK_OPTIONS: { value: string; label: string }[] = [
 // 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/
@@ -63,4 +66,5 @@ export const SERVER_PROTOCOLS = new Set<string>([
   'socks',
   'http',
   'hysteria',
+  'masque',
 ]);

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

@@ -67,6 +67,8 @@ export function newStreamSlice(network: string): Record<string, unknown> {
       };
     case 'xdrive':
       return { network: 'xdrive', xdriveSettings: XDriveStreamSettingsSchema.parse({}) };
+    case 'masque':
+      return { network: 'masque', masqueSettings: {} };
     default:
       return { network: 'tcp', tcpSettings: { header: { type: 'none' } } };
   }
@@ -89,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)
@@ -100,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>
+  );
+}

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

+ 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>;

+ 5 - 0
frontend/src/schemas/protocols/stream/index.ts

@@ -8,6 +8,7 @@ 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';
@@ -20,6 +21,7 @@ export * from './grpc';
 export * from './httpupgrade';
 export * from './hysteria';
 export * from './kcp';
+export * from './masque';
 export * from './sockopt';
 export * from './tcp';
 export * from './ws';
@@ -35,6 +37,7 @@ export const NetworkSchema = z.enum([
   'xhttp',
   'hysteria',
   'xdrive',
+  'masque',
 ]);
 export type Network = z.infer<typeof NetworkSchema>;
 
@@ -58,6 +61,8 @@ 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'),

+ 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>;

+ 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"`;

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

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

+ 55 - 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');
@@ -362,6 +379,44 @@ describe('InboundFormModal', () => {
     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'] },
+    });
+  });
+
   // 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 () => {

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

+ 74 - 0
frontend/src/test/outbound-form-modal.test.tsx

@@ -378,3 +378,77 @@ describe('OutboundFormModal', () => {
     });
   });
 });
+
+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();
+  });
+});

+ 45 - 0
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.
@@ -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

+ 28 - 0
internal/sub/json_service.go

@@ -663,6 +663,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 {
@@ -1009,6 +1011,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,

+ 83 - 0
internal/sub/masque_test.go

@@ -0,0 +1,83 @@
+package sub
+
+import (
+	"encoding/json"
+	"reflect"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// MASQUE has no share-link format, so the JSON subscription is the only way a client
+// gets one: a masque outbound authenticating with the client's email and password.
+func TestSubJsonServiceMasqueClientConfig(t *testing.T) {
+	inbound := &model.Inbound{
+		Listen: "203.0.113.9", Port: 8443, Protocol: model.MASQUE, Remark: "masque",
+		Settings: `{"clients":[{"email":"[email protected]","password":"pw-ivy"}],"address":["10.14.0.1/24"]}`,
+		StreamSettings: `{"network":"masque","security":"tls",
+			"masqueSettings":{"path":"/.well-known/masque/ip/*/*/"},
+			"tlsSettings":{"serverName":"masque.example.test","alpn":["h3"],
+				"certificates":[{"certificateFile":"/etc/ssl/m.crt","keyFile":"/etc/ssl/m.key"}],
+				"settings":{"fingerprint":"chrome"}}}`,
+	}
+	client := model.Client{Email: "[email protected]", Password: "pw-ivy"}
+
+	configs := NewSubJsonService("", "", "", "", nil).getConfig(&SubService{address: "sub.example.com"}, inbound, client, "sub.example.com")
+	if len(configs) != 1 {
+		t.Fatalf("got %d configs, want one", len(configs))
+	}
+	var config struct {
+		Outbounds []struct {
+			Protocol       string         `json:"protocol"`
+			Settings       map[string]any `json:"settings"`
+			StreamSettings map[string]any `json:"streamSettings"`
+		} `json:"outbounds"`
+	}
+	if err := json.Unmarshal(configs[0], &config); err != nil {
+		t.Fatalf("decode config: %v", err)
+	}
+	proxy := config.Outbounds[0]
+	if proxy.Protocol != "masque" {
+		t.Fatalf("first outbound protocol = %q, want masque", proxy.Protocol)
+	}
+	if proxy.Settings["address"] != "203.0.113.9" || proxy.Settings["port"] != float64(8443) {
+		t.Fatalf("masque settings = %v, want the inbound address and port", proxy.Settings)
+	}
+	wantMasque := map[string]any{"path": "/.well-known/masque/ip/*/*/", "user": "[email protected]", "pass": "pw-ivy"}
+	if got := proxy.StreamSettings["masqueSettings"]; !reflect.DeepEqual(got, wantMasque) {
+		t.Fatalf("masqueSettings = %v, want %v", got, wantMasque)
+	}
+	tls, _ := proxy.StreamSettings["tlsSettings"].(map[string]any)
+	if proxy.StreamSettings["network"] != "masque" || proxy.StreamSettings["security"] != "tls" ||
+		tls["serverName"] != "masque.example.test" || tls["certificates"] != nil {
+		t.Fatalf("stream = %v, want masque over client-side TLS to masque.example.test", proxy.StreamSettings)
+	}
+}
+
+// The subscription SQL allowlist decides which inbounds a subId can see at all; left
+// off it, a MASQUE client's JSON subscription would come back empty.
+func TestGetInboundsBySubIdIncludesMasque(t *testing.T) {
+	initSubDB(t)
+	db := database.GetDB()
+
+	in := &model.Inbound{Port: 8443, Protocol: model.MASQUE, Enable: true, Tag: "masque-sub", Settings: `{"clients":[],"address":["10.14.0.1/24"]}`}
+	if err := db.Create(in).Error; err != nil {
+		t.Fatalf("create inbound: %v", err)
+	}
+	rec := &model.ClientRecord{Email: "u@masque", SubID: "submasque", Enable: true}
+	if err := db.Create(rec).Error; err != nil {
+		t.Fatalf("create client: %v", err)
+	}
+	if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: in.Id}).Error; err != nil {
+		t.Fatalf("create link: %v", err)
+	}
+
+	inbounds, err := (&SubService{}).getInboundsBySubId("submasque")
+	if err != nil {
+		t.Fatalf("getInboundsBySubId: %v", err)
+	}
+	if len(inbounds) != 1 || inbounds[0].Id != in.Id {
+		t.Fatalf("masque inbound not returned for subId: %+v", inbounds)
+	}
+}

+ 1 - 1
internal/sub/service.go

@@ -685,7 +685,7 @@ func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error)
 		JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id
 		JOIN clients ON clients.id = client_inbounds.client_id
 		WHERE
-			inbounds.protocol in ('vmess','vless','trojan','shadowsocks','hysteria','wireguard','amneziawg','mtproto','tuic')
+			inbounds.protocol in ('vmess','vless','trojan','shadowsocks','hysteria','wireguard','amneziawg','mtproto','tuic','masque')
 			AND clients.sub_id = ? AND inbounds.enable = ?
 	)`, subId, true).Order("sub_sort_index ASC").Order("id ASC").Find(&inbounds).Error
 	if err != nil {

+ 1 - 1
internal/web/job/check_client_ip_job.go

@@ -694,7 +694,7 @@ func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, c
 	// wireguard stays out: keepAlive marshals as a number, AddUser wants a string.
 	protocol := string(inbound.Protocol)
 	switch protocol {
-	case "vmess", "vless", "trojan", "shadowsocks", "hysteria":
+	case "vmess", "vless", "trojan", "shadowsocks", "hysteria", "masque":
 		// supported protocols, continue
 	default:
 		logger.Warningf("[LIMIT_IP] Temporary disconnect is not supported for protocol %s on inbound %s", protocol, inbound.Tag)

+ 1 - 1
internal/web/service/client_crud.go

@@ -411,7 +411,7 @@ func (s *ClientService) fillProtocolDefaults(c *model.Client, ib *model.Inbound)
 		if c.ID == "" {
 			c.ID = uuid.NewString()
 		}
-	case model.Trojan:
+	case model.Trojan, model.MASQUE:
 		if c.Password == "" {
 			c.Password = strings.ReplaceAll(uuid.NewString(), "-", "")
 		}

+ 2 - 2
internal/web/service/client_inbound_apply.go

@@ -443,7 +443,7 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model
 			return false, common.NewError("client email is required")
 		}
 		switch oldInbound.Protocol {
-		case "trojan":
+		case "trojan", "masque":
 			if client.Password == "" {
 				return false, common.NewError("empty client ID")
 			}
@@ -688,7 +688,7 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
 
 	newClientId := ""
 	switch oldInbound.Protocol {
-	case "trojan":
+	case "trojan", "masque":
 		newClientId = clients[0].Password
 	case "shadowsocks":
 		newClientId = clients[0].Email

+ 6 - 1
internal/web/service/inbound.go

@@ -656,6 +656,7 @@ func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) {
 		model.Hysteria:    true,
 		model.WireGuard:   true,
 		model.Tunnel:      true,
+		model.MASQUE:      true,
 	}
 
 	if !protocolsWithStream[inbound.Protocol] {
@@ -1241,7 +1242,7 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
 	// Secure client ID
 	for _, client := range clients {
 		switch inbound.Protocol {
-		case "trojan":
+		case "trojan", "masque":
 			if client.Password == "" {
 				return inbound, false, common.NewError("empty client ID")
 			}
@@ -1767,6 +1768,10 @@ func (s *InboundService) validateUpdatedInboundClients(inbound *model.Inbound) e
 			if client.Auth == "" {
 				return common.NewError("empty client ID")
 			}
+		case model.MASQUE:
+			if client.Password == "" {
+				return common.NewError("empty client ID")
+			}
 		case model.TUIC:
 			if client.ID == "" {
 				return common.NewError("empty client ID")

+ 1 - 1
internal/web/service/inbound_clients.go

@@ -230,7 +230,7 @@ func (s *InboundService) buildTargetClientFromSource(source model.Client, target
 			inboundCanEnableTlsFlow(string(targetProtocol), targetInbound.StreamSettings, targetInbound.Settings) {
 			target.Flow = flow
 		}
-	case model.Trojan, model.Shadowsocks:
+	case model.Trojan, model.Shadowsocks, model.MASQUE:
 		target.Password = s.generateRandomCredential(targetProtocol)
 	case model.Hysteria:
 		target.Auth = s.generateRandomCredential(targetProtocol)

+ 101 - 0
internal/web/service/inbound_masque_test.go

@@ -0,0 +1,101 @@
+package service
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+const masqueTestStream = `{"network":"masque","security":"tls","masqueSettings":{"path":"/.well-known/masque/ip/*/*/"},
+	"tlsSettings":{"alpn":["h3"],"certificates":[{"certificateFile":"/etc/ssl/certs/m.crt","keyFile":"/etc/ssl/private/m.key"}]}}`
+
+// GetXrayConfig rebuilds every inbound's users from the clients table; a MASQUE user
+// emitted without its password makes xray-core refuse the whole config at startup.
+func TestGetXrayConfig_EmitsMasqueUserPasswords(t *testing.T) {
+	setupConflictDB(t)
+	in := &model.Inbound{
+		Tag: "in-8443-masque", Enable: true, Listen: "127.0.0.1", Port: 8443, Protocol: model.MASQUE,
+		Settings:       `{"clients":[{"email":"[email protected]","password":"masque-pass-ivy","enable":true}],"address":["10.14.0.1/24"]}`,
+		StreamSettings: masqueTestStream,
+	}
+	if _, _, err := (&InboundService{}).AddInbound(in); err != nil {
+		t.Fatalf("AddInbound: %v", err)
+	}
+
+	cfg, err := (&XrayService{}).GetXrayConfig()
+	if err != nil {
+		t.Fatalf("GetXrayConfig: %v", err)
+	}
+	for i := range cfg.InboundConfigs {
+		if cfg.InboundConfigs[i].Tag != "in-8443-masque" {
+			continue
+		}
+		var settings struct {
+			Clients []map[string]any `json:"clients"`
+		}
+		if err := json.Unmarshal(cfg.InboundConfigs[i].Settings, &settings); err != nil {
+			t.Fatalf("decode emitted settings: %v", err)
+		}
+		if len(settings.Clients) != 1 || settings.Clients[0]["pass"] != "masque-pass-ivy" {
+			t.Fatalf("emitted clients = %v, want one with pass masque-pass-ivy", settings.Clients)
+		}
+		raw, err := json.Marshal(cfg.InboundConfigs[i])
+		if err != nil {
+			t.Fatalf("marshal emitted inbound: %v", err)
+		}
+		var emitted map[string]any
+		if err := json.Unmarshal(raw, &emitted); err != nil {
+			t.Fatalf("decode emitted inbound: %v", err)
+		}
+		if stream, _ := emitted["streamSettings"].(map[string]any); stream["network"] != "masque" {
+			t.Fatalf("emitted streamSettings = %v, want the stored masque transport", emitted["streamSettings"])
+		}
+		assertXrayAccepts(t, "the emitted MASQUE inbound", buildGoldenInbound(t, emitted))
+		return
+	}
+	t.Fatal("inbound in-8443-masque not found in the generated config")
+}
+
+// A client added to a MASQUE inbound without a password could never authenticate,
+// and xray-core refuses the whole inbound over one empty pass.
+func TestFillProtocolDefaults_MintsMasquePassword(t *testing.T) {
+	client := model.Client{Email: "[email protected]"}
+	if err := (&ClientService{}).fillProtocolDefaults(&client, &model.Inbound{Protocol: model.MASQUE}); err != nil {
+		t.Fatalf("fillProtocolDefaults: %v", err)
+	}
+	if len(client.Password) != 32 {
+		t.Fatalf("Password = %q, want a minted 32-character password", client.Password)
+	}
+	kept := model.Client{Email: "[email protected]", Password: "chosen"}
+	if err := (&ClientService{}).fillProtocolDefaults(&kept, &model.Inbound{Protocol: model.MASQUE}); err != nil {
+		t.Fatalf("fillProtocolDefaults: %v", err)
+	}
+	if kept.Password != "chosen" {
+		t.Fatalf("Password = %q, want the chosen password kept", kept.Password)
+	}
+}
+
+// Editing a MASQUE client must key on its password like trojan; falling back to the
+// empty UUID refused every MASQUE client edit with "empty client ID".
+func TestUpdateInboundClient_MasquePasswordChange(t *testing.T) {
+	setupBulkDB(t)
+	svc := &ClientService{}
+	source := []model.Client{{Email: "ivy@x", Password: "pw-old", SubID: "sub-ivy", Enable: true}}
+	ib := mkInbound(t, 22101, model.MASQUE, clientsSettings(t, source))
+	if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
+		t.Fatalf("seed linkage: %v", err)
+	}
+
+	updated := []model.Client{{Email: "ivy@x", Password: "pw-new", SubID: "sub-ivy", Enable: true}}
+	if _, err := svc.UpdateInboundClient(&InboundService{}, &model.Inbound{
+		Id:       ib.Id,
+		Settings: clientsSettings(t, updated),
+	}, "ivy@x"); err != nil {
+		t.Fatalf("UpdateInboundClient: %v", err)
+	}
+
+	if rec := lookupClientRecord(t, "ivy@x"); rec.Password != "pw-new" {
+		t.Fatalf("stored password = %q, want pw-new", rec.Password)
+	}
+}

+ 2 - 0
internal/web/service/inbound_protocol.go

@@ -70,6 +70,7 @@ var nodeEligibleProtocols = map[model.Protocol]bool{
 	model.MTProto:     true,
 	model.AmneziaWG:   true,
 	model.TUIC:        true,
+	model.MASQUE:      true,
 }
 
 // nodeProtocolFirstRelease is the panel release that introduced each protocol
@@ -78,6 +79,7 @@ var nodeProtocolFirstRelease = map[model.Protocol]string{
 	model.MTProto:   "v3.5.0",
 	model.AmneziaWG: "v3.7.0",
 	model.TUIC:      "v3.8.0",
+	model.MASQUE:    "v3.9.1",
 }
 
 // checkNodeCanHostProtocol refuses assigning protocol to nodeID unless the

+ 41 - 0
internal/web/service/inbound_protocol_masque_test.go

@@ -0,0 +1,41 @@
+package service
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// MASQUE is native Xray, so a node runs it, but a node panel older than the release
+// that knows it would hand the clients' "password" to a core that reads "pass".
+func TestCheckNodeCanHostProtocol_Masque(t *testing.T) {
+	setupConflictDB(t)
+	tests := []struct {
+		version string
+		wantErr string
+	}{
+		{"v3.9.0", "need v3.9.1 or newer"},
+		{"v3.9.1", ""},
+		{"dev+1d1128cf", ""},
+	}
+	for i, tc := range tests {
+		t.Run(tc.version, func(t *testing.T) {
+			node := &model.Node{Name: "masque-node-" + tc.version, Address: "127.0.0.1", Port: 2096 + i, PanelVersion: tc.version}
+			if err := database.GetDB().Create(node).Error; err != nil {
+				t.Fatalf("create node: %v", err)
+			}
+			err := checkNodeCanHostProtocol(database.GetDB(), node.Id, model.MASQUE)
+			if tc.wantErr == "" {
+				if err != nil {
+					t.Fatalf("node on %s refused MASQUE: %v", tc.version, err)
+				}
+				return
+			}
+			if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
+				t.Fatalf("node on %s: err = %v, want one containing %q", tc.version, err, tc.wantErr)
+			}
+		})
+	}
+}

+ 14 - 1
internal/web/service/outbound/outbound.go

@@ -5,6 +5,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"net"
+	"slices"
 	"strconv"
 	"strings"
 	"sync"
@@ -225,6 +226,9 @@ func outboundTransportIsUDP(ob map[string]any) bool {
 	if protocol, _ := ob["protocol"].(string); equalsAnyFold(protocol, "hysteria", "wireguard", "amneziawg") {
 		return true
 	}
+	if protocol, _ := ob["protocol"].(string); equalsAnyFold(protocol, "masque") {
+		return masqueDialsHTTP3(ob)
+	}
 	if stream, ok := ob["streamSettings"].(map[string]any); ok {
 		// The core resolves "kcp" and "mkcp" to the same mKCP transport.
 		if n, _ := stream["network"].(string); equalsAnyFold(n, "hysteria", "kcp", "mkcp", "quic") {
@@ -234,6 +238,15 @@ func outboundTransportIsUDP(ob map[string]any) bool {
 	return false
 }
 
+// masqueDialsHTTP3 mirrors the core's MASQUE client, which falls back to HTTP/2 over
+// TCP only when the TLS ALPN offers h2 and not h3.
+func masqueDialsHTTP3(ob map[string]any) bool {
+	stream, _ := ob["streamSettings"].(map[string]any)
+	tlsSettings, _ := stream["tlsSettings"].(map[string]any)
+	alpn, _ := tlsSettings["alpn"].([]any)
+	return !slices.Contains(alpn, any("h2")) || slices.Contains(alpn, any("h3"))
+}
+
 // equalsAnyFold mirrors the core, which lowercases a protocol id and a
 // transport name before it resolves either of them.
 func equalsAnyFold(value string, want ...string) bool {
@@ -281,7 +294,7 @@ func extractOutboundEndpoints(ob map[string]any) []string {
 		if len(out) == 0 {
 			addServer(settings["address"], settings["port"])
 		}
-	case "hysteria":
+	case "hysteria", "masque":
 		addServer(settings["address"], settings["port"])
 	case "trojan", "shadowsocks", "http", "socks":
 		if servers, ok := settings["servers"].([]any); ok {

+ 12 - 0
internal/web/service/outbound/outbound_endpoints_test.go

@@ -52,3 +52,15 @@ func TestExtractOutboundEndpointsVLESS(t *testing.T) {
 		})
 	}
 }
+
+// A MASQUE outbound names its server flat in settings, like hysteria; without it the
+// outbound test has no endpoint to resolve and reports the outbound unreachable.
+func TestExtractOutboundEndpointsMasque(t *testing.T) {
+	got := extractOutboundEndpoints(map[string]any{
+		"protocol": "masque",
+		"settings": map[string]any{"address": "masque.example.com", "port": float64(8443)},
+	})
+	if want := []string{"masque.example.com:8443"}; !reflect.DeepEqual(got, want) {
+		t.Fatalf("extractOutboundEndpoints() = %v, want %v", got, want)
+	}
+}

+ 3 - 0
internal/web/service/outbound/probe_protocol_case_test.go

@@ -51,6 +51,9 @@ func TestOutboundTransportIsUDPMatchesTheCore(t *testing.T) {
 		{"capitalised wireguard", map[string]any{"protocol": "WireGuard"}, true},
 		{"upper hysteria", map[string]any{"protocol": "HYSTERIA"}, true},
 		{"amneziawg", map[string]any{"protocol": "amneziawg"}, true},
+		// The core's MASQUE client dials HTTP/3 over QUIC unless the ALPN offers h2 without h3.
+		{"masque over http/3", map[string]any{"protocol": "masque", "streamSettings": map[string]any{"network": "masque"}}, true},
+		{"masque pinned to http/2", map[string]any{"protocol": "masque", "streamSettings": map[string]any{"network": "masque", "tlsSettings": map[string]any{"alpn": []any{"h2"}}}}, false},
 		{"kcp transport", map[string]any{"streamSettings": map[string]any{"network": "kcp"}}, true},
 		{"kcp transport capitalised", map[string]any{"streamSettings": map[string]any{"network": "KCP"}}, true},
 		{"mkcp alias", map[string]any{"streamSettings": map[string]any{"network": "mkcp"}}, true},

+ 23 - 0
internal/web/service/port_conflict.go

@@ -4,6 +4,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"net"
+	"slices"
 	"strings"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
@@ -30,6 +31,8 @@ func inboundTransports(protocol model.Protocol, streamSettings, settings string)
 		return transportUDP
 	case model.MTProto:
 		return transportTCP
+	case model.MASQUE:
+		return masqueTransports(streamSettings)
 	}
 
 	var bits transportBits
@@ -93,6 +96,26 @@ func inboundTransports(protocol model.Protocol, streamSettings, settings string)
 	return bits
 }
 
+// masqueTransports mirrors xray-core's MASQUE listener: HTTP/2 on TCP when the TLS
+// ALPN offers h2, HTTP/3 on UDP when it offers h3 or does not offer h2.
+func masqueTransports(streamSettings string) transportBits {
+	var stream struct {
+		TLSSettings struct {
+			ALPN []string `json:"alpn"`
+		} `json:"tlsSettings"`
+	}
+	_ = json.Unmarshal([]byte(streamSettings), &stream)
+	h2 := slices.Contains(stream.TLSSettings.ALPN, "h2")
+	var bits transportBits
+	if h2 {
+		bits |= transportTCP
+	}
+	if !h2 || slices.Contains(stream.TLSSettings.ALPN, "h3") {
+		bits |= transportUDP
+	}
+	return bits
+}
+
 // bindAddr is a listen address plus sockopt.v6only. xray listens on "tcp"/"udp",
 // so Go opens every wildcard, 0.0.0.0 included, dual-stack unless v6only is set.
 type bindAddr struct {

+ 7 - 0
internal/web/service/port_conflict_test.go

@@ -75,6 +75,13 @@ func TestInboundTransports(t *testing.T) {
 		{"hysteria forced udp", model.Hysteria, `{"network":"tcp"}`, ``, transportUDP},
 		{"wireguard forced udp", model.WireGuard, ``, ``, transportUDP},
 
+		// MASQUE serves HTTP/3 on UDP unless the TLS ALPN offers only h2, and adds an
+		// HTTP/2 listener on TCP whenever h2 is offered.
+		{"masque without alpn is udp", model.MASQUE, `{"network":"masque","tlsSettings":{}}`, ``, transportUDP},
+		{"masque h3 is udp", model.MASQUE, `{"network":"masque","tlsSettings":{"alpn":["h3"]}}`, ``, transportUDP},
+		{"masque h2 is tcp", model.MASQUE, `{"network":"masque","tlsSettings":{"alpn":["h2"]}}`, ``, transportTCP},
+		{"masque h3+h2 is both", model.MASQUE, `{"network":"masque","tlsSettings":{"alpn":["h3","h2"]}}`, ``, transportTCP | transportUDP},
+
 		{"shadowsocks tcp,udp", model.Shadowsocks, ``, `{"network":"tcp,udp"}`, transportTCP | transportUDP},
 		{"shadowsocks udp only", model.Shadowsocks, ``, `{"network":"udp"}`, transportUDP},
 		{"shadowsocks tcp only", model.Shadowsocks, ``, `{"network":"tcp"}`, transportTCP},

+ 4 - 0
internal/web/service/xray.go

@@ -282,6 +282,10 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
 				if c.Auth != "" {
 					entry["auth"] = c.Auth
 				}
+			case model.MASQUE:
+				if c.Password != "" {
+					entry["password"] = c.Password
+				}
 			case model.WireGuard:
 				if inboundClient, ok := wireguardClientsByEmail[strings.ToLower(strings.TrimSpace(c.Email))]; ok {
 					c.AllowedIPs = inboundClient.AllowedIPs

+ 9 - 2
internal/web/translation/ar-EG.json

@@ -718,6 +718,10 @@
           "holeTimeout": "مهلة المقطع المفقود (ms)",
           "sessionTtl": "مدة صلاحية الجلسة (s)",
           "concurrency": "التزامن"
+        },
+        "masque": {
+          "addressPool": "مجمّع العناوين",
+          "addressPoolDesc": "تُخصَّص عناوين النفق من هذه البادئات — بحد أقصى IPv4 واحد و IPv6 واحد."
         }
       },
       "info": {
@@ -813,7 +817,7 @@
       "limitIpFail2banWindows": "Fail2ban غير متوفّر على نظام Windows، لذا لا يمكن تطبيق حد عناوين IP.",
       "limitIpDisabled": "ميزة حد عناوين IP معطّلة على هذا الخادم.",
       "password": "كلمة المرور",
-      "passwordDesc": "تُستخدم فقط من قبل عملاء Trojan و Shadowsocks؛ ويتم تجاهلها لـ VLESS و VMess و Hysteria و WireGuard.",
+      "passwordDesc": "يستخدمها عملاء Trojan و Shadowsocks و TUIC و MASQUE؛ ويتم تجاهلها لـ VLESS و VMess و Hysteria و WireGuard.",
       "subId": "معرّف الاشتراك",
       "subIdDesc": "يُستخدم أيضًا كرمز دعوة لهذا العميل في بوت تيليجرام: من يرسله إلى البوت يُربط بهذا العميل. اجعله طويلًا وعشوائيًا — فالمعرّف القصير أو السهل التخمين قد يستولي عليه شخص آخر.",
       "online": "متصل",
@@ -1961,7 +1965,10 @@
         "interface": "الواجهة",
         "proxyProtocol": "Proxy protocol",
         "tcpUserTimeoutMs": "TCP user timeout (ms)",
-        "tcpKeepAliveIdleS": "TCP keep-alive idle (ثانية)"
+        "tcpKeepAliveIdleS": "TCP keep-alive idle (ثانية)",
+        "masqueWarpDesc": "الاتصال بـ Cloudflare WARP عبر MASQUE بمفتاح ECDSA P-256 مسجَّل؛ يكون host و path افتراضيًا نقطة نهاية Cloudflare.",
+        "masqueWarpPublicKey": "المفتاح العام لنقطة النهاية",
+        "masqueWarpAddress": "عناوين النفق"
       },
       "outbound": {
         "tag": "الوسم",

+ 9 - 2
internal/web/translation/en-US.json

@@ -719,6 +719,10 @@
           "holeTimeout": "Missing segment timeout (ms)",
           "sessionTtl": "Session TTL (s)",
           "concurrency": "Concurrency"
+        },
+        "masque": {
+          "addressPool": "Address pool",
+          "addressPoolDesc": "Tunnel addresses are leased from these prefixes — at most one IPv4 and one IPv6."
         }
       },
       "info": {
@@ -813,7 +817,7 @@
       "limitIpFail2banWindows": "Fail2ban is not available on Windows, so the IP limit cannot be enforced.",
       "limitIpDisabled": "The IP limit feature is disabled on this server.",
       "password": "Password",
-      "passwordDesc": "Used by Trojan, Shadowsocks, and TUIC clients; ignored for VLESS, VMess, Hysteria, and WireGuard.",
+      "passwordDesc": "Used by Trojan, Shadowsocks, TUIC, and MASQUE clients; ignored for VLESS, VMess, Hysteria, and WireGuard.",
       "subId": "Subscription ID",
       "subIdDesc": "Also works as this client's Telegram bot invite code: whoever sends it to the bot is linked to this client. Keep it long and random — a short or guessable ID can be claimed by someone else.",
       "online": "Online",
@@ -2079,7 +2083,10 @@
         "interface": "Interface",
         "proxyProtocol": "Proxy protocol",
         "tcpUserTimeoutMs": "TCP user timeout (ms)",
-        "tcpKeepAliveIdleS": "TCP keep-alive idle (s)"
+        "tcpKeepAliveIdleS": "TCP keep-alive idle (s)",
+        "masqueWarpDesc": "Connect to Cloudflare WARP over MASQUE with an enrolled ECDSA P-256 key; host and path default to Cloudflare's endpoint.",
+        "masqueWarpPublicKey": "Endpoint public key",
+        "masqueWarpAddress": "Tunnel addresses"
       },
       "outbound": {
         "tag": "Tag",

+ 9 - 2
internal/web/translation/es-ES.json

@@ -718,6 +718,10 @@
           "holeTimeout": "Tiempo de espera de segmento perdido (ms)",
           "sessionTtl": "TTL de sesión (s)",
           "concurrency": "Concurrencia"
+        },
+        "masque": {
+          "addressPool": "Pool de direcciones",
+          "addressPoolDesc": "Las direcciones del túnel se asignan de estos prefijos: como máximo uno IPv4 y uno IPv6."
         }
       },
       "info": {
@@ -813,7 +817,7 @@
       "limitIpFail2banWindows": "Fail2ban no está disponible en Windows, por lo que no se puede aplicar el límite de IP.",
       "limitIpDisabled": "La función de límite de IP está deshabilitada en este servidor.",
       "password": "Contraseña",
-      "passwordDesc": "Solo la usan los clientes Trojan y Shadowsocks; se ignora para VLESS, VMess, Hysteria y WireGuard.",
+      "passwordDesc": "La usan los clientes Trojan, Shadowsocks, TUIC y MASQUE; se ignora para VLESS, VMess, Hysteria y WireGuard.",
       "subId": "ID de suscripción",
       "subIdDesc": "También sirve como código de invitación de este cliente en el bot de Telegram: quien lo envíe al bot queda vinculado a este cliente. Mantenlo largo y aleatorio: un ID corto o fácil de adivinar puede ser reclamado por otra persona.",
       "online": "En línea",
@@ -1961,7 +1965,10 @@
         "interface": "Interfaz",
         "proxyProtocol": "Proxy protocol",
         "tcpUserTimeoutMs": "TCP user timeout (ms)",
-        "tcpKeepAliveIdleS": "TCP keep-alive idle (s)"
+        "tcpKeepAliveIdleS": "TCP keep-alive idle (s)",
+        "masqueWarpDesc": "Conecta a Cloudflare WARP mediante MASQUE con una clave ECDSA P-256 registrada; host y path usan por defecto el endpoint de Cloudflare.",
+        "masqueWarpPublicKey": "Clave pública del endpoint",
+        "masqueWarpAddress": "Direcciones del túnel"
       },
       "outbound": {
         "tag": "Etiqueta",

+ 9 - 2
internal/web/translation/fa-IR.json

@@ -718,6 +718,10 @@
           "holeTimeout": "مهلت قطعهٔ گمشده (میلی‌ثانیه)",
           "sessionTtl": "TTL نشست (ثانیه)",
           "concurrency": "هم‌زمانی"
+        },
+        "masque": {
+          "addressPool": "مخزن آدرس",
+          "addressPoolDesc": "آدرس‌های تونل از این پیشوندها تخصیص داده می‌شوند — حداکثر یک IPv4 و یک IPv6."
         }
       },
       "info": {
@@ -813,7 +817,7 @@
       "limitIpFail2banWindows": "Fail2ban روی ویندوز در دسترس نیست، بنابراین محدودیت IP قابل اعمال نیست.",
       "limitIpDisabled": "قابلیت محدودیت IP روی این سرور غیرفعال است.",
       "password": "رمز عبور",
-      "passwordDesc": "فقط توسط کلاینت‌های Trojan و Shadowsocks استفاده می‌شود؛ برای VLESS، VMess، Hysteria و WireGuard نادیده گرفته می‌شود.",
+      "passwordDesc": "توسط کلاینت‌های Trojan، Shadowsocks، TUIC و MASQUE استفاده می‌شود؛ برای VLESS، VMess، Hysteria و WireGuard نادیده گرفته می‌شود.",
       "subId": "شناسه اشتراک",
       "subIdDesc": "این شناسه، کد دعوت این کاربر در ربات تلگرام هم هست: هر کسی آن را به ربات بفرستد به این کاربر متصل می‌شود. آن را طولانی و تصادفی نگه دارید؛ شناسهٔ کوتاه یا قابل حدس ممکن است توسط شخص دیگری تصاحب شود.",
       "online": "آنلاین",
@@ -1961,7 +1965,10 @@
         "interface": "رابط",
         "proxyProtocol": "Proxy Protocol",
         "tcpUserTimeoutMs": "TCP user timeout (ms)",
-        "tcpKeepAliveIdleS": "TCP keep-alive idle (s)"
+        "tcpKeepAliveIdleS": "TCP keep-alive idle (s)",
+        "masqueWarpDesc": "اتصال به Cloudflare WARP از طریق MASQUE با یک کلید ECDSA P-256 ثبت‌شده؛ host و path به‌طور پیش‌فرض endpoint کلودفلر هستند.",
+        "masqueWarpPublicKey": "کلید عمومی endpoint",
+        "masqueWarpAddress": "آدرس‌های تونل"
       },
       "outbound": {
         "tag": "تگ",

+ 9 - 2
internal/web/translation/id-ID.json

@@ -718,6 +718,10 @@
           "holeTimeout": "Batas waktu segmen hilang (ms)",
           "sessionTtl": "TTL sesi (s)",
           "concurrency": "Konkurensi"
+        },
+        "masque": {
+          "addressPool": "Kumpulan alamat",
+          "addressPoolDesc": "Alamat terowongan diberikan dari prefiks ini — maksimal satu IPv4 dan satu IPv6."
         }
       },
       "info": {
@@ -813,7 +817,7 @@
       "limitIpFail2banWindows": "Fail2ban tidak tersedia di Windows, sehingga batas IP tidak dapat diterapkan.",
       "limitIpDisabled": "Fitur batas IP dinonaktifkan di server ini.",
       "password": "Kata sandi",
-      "passwordDesc": "Hanya digunakan oleh klien Trojan dan Shadowsocks; diabaikan untuk VLESS, VMess, Hysteria, dan WireGuard.",
+      "passwordDesc": "Digunakan oleh klien Trojan, Shadowsocks, TUIC, dan MASQUE; diabaikan untuk VLESS, VMess, Hysteria, dan WireGuard.",
       "subId": "ID Langganan",
       "subIdDesc": "Juga berfungsi sebagai kode undangan bot Telegram untuk klien ini: siapa pun yang mengirimkannya ke bot akan ditautkan ke klien ini. Buat panjang dan acak — ID yang pendek atau mudah ditebak bisa diklaim orang lain.",
       "online": "Online",
@@ -1961,7 +1965,10 @@
         "interface": "Interface",
         "proxyProtocol": "Proxy protocol",
         "tcpUserTimeoutMs": "TCP user timeout (ms)",
-        "tcpKeepAliveIdleS": "TCP keep-alive idle (d)"
+        "tcpKeepAliveIdleS": "TCP keep-alive idle (d)",
+        "masqueWarpDesc": "Terhubung ke Cloudflare WARP melalui MASQUE dengan kunci ECDSA P-256 yang terdaftar; host dan path default ke endpoint Cloudflare.",
+        "masqueWarpPublicKey": "Kunci publik endpoint",
+        "masqueWarpAddress": "Alamat terowongan"
       },
       "outbound": {
         "tag": "Tag",

+ 9 - 2
internal/web/translation/ja-JP.json

@@ -718,6 +718,10 @@
           "holeTimeout": "欠落セグメントのタイムアウト(ms)",
           "sessionTtl": "セッション TTL(秒)",
           "concurrency": "同時実行数"
+        },
+        "masque": {
+          "addressPool": "アドレスプール",
+          "addressPoolDesc": "トンネルのアドレスはこれらのプレフィックスから割り当てられます(IPv4 と IPv6 をそれぞれ最大 1 つ)。"
         }
       },
       "info": {
@@ -813,7 +817,7 @@
       "limitIpFail2banWindows": "Windows では Fail2ban を利用できないため、IP 制限を適用できません。",
       "limitIpDisabled": "このサーバーでは IP 制限機能が無効になっています。",
       "password": "パスワード",
-      "passwordDesc": "Trojan と Shadowsocks のクライアントのみが使用します。VLESS、VMess、Hysteria、WireGuard では無視されます。",
+      "passwordDesc": "Trojan、Shadowsocks、TUIC、MASQUE のクライアントが使用します。VLESS、VMess、Hysteria、WireGuard では無視されます。",
       "subId": "サブスクリプション ID",
       "subIdDesc": "このクライアントの Telegram ボット招待コードとしても使われます。ボットに送信した人がこのクライアントに紐付けられます。長くランダムな値にしてください。短い、または推測しやすい ID は他人に取得されるおそれがあります。",
       "online": "オンライン",
@@ -1961,7 +1965,10 @@
         "interface": "インターフェース",
         "proxyProtocol": "Proxy protocol",
         "tcpUserTimeoutMs": "TCP user timeout (ms)",
-        "tcpKeepAliveIdleS": "TCP keep-alive idle (秒)"
+        "tcpKeepAliveIdleS": "TCP keep-alive idle (秒)",
+        "masqueWarpDesc": "登録済みの ECDSA P-256 鍵で MASQUE 経由で Cloudflare WARP に接続します。host と path の既定値は Cloudflare のエンドポイントです。",
+        "masqueWarpPublicKey": "エンドポイント公開鍵",
+        "masqueWarpAddress": "トンネルアドレス"
       },
       "outbound": {
         "tag": "タグ",

+ 9 - 2
internal/web/translation/pt-BR.json

@@ -718,6 +718,10 @@
           "holeTimeout": "Timeout de segmento ausente (ms)",
           "sessionTtl": "TTL da sessão (s)",
           "concurrency": "Concorrência"
+        },
+        "masque": {
+          "addressPool": "Pool de endereços",
+          "addressPoolDesc": "Os endereços do túnel são atribuídos a partir destes prefixos — no máximo um IPv4 e um IPv6."
         }
       },
       "info": {
@@ -813,7 +817,7 @@
       "limitIpFail2banWindows": "O Fail2ban não está disponível no Windows, portanto o limite de IP não pode ser aplicado.",
       "limitIpDisabled": "O recurso de limite de IP está desativado neste servidor.",
       "password": "Senha",
-      "passwordDesc": "Usada apenas pelos clientes Trojan e Shadowsocks; ignorada para VLESS, VMess, Hysteria e WireGuard.",
+      "passwordDesc": "Usada pelos clientes Trojan, Shadowsocks, TUIC e MASQUE; ignorada para VLESS, VMess, Hysteria e WireGuard.",
       "subId": "ID da assinatura",
       "subIdDesc": "Também funciona como código de convite deste cliente no bot do Telegram: quem enviá-lo ao bot fica vinculado a este cliente. Mantenha-o longo e aleatório — um ID curto ou fácil de adivinhar pode ser reivindicado por outra pessoa.",
       "online": "Online",
@@ -1961,7 +1965,10 @@
         "interface": "Interface",
         "proxyProtocol": "Proxy protocol",
         "tcpUserTimeoutMs": "TCP user timeout (ms)",
-        "tcpKeepAliveIdleS": "TCP keep-alive idle (s)"
+        "tcpKeepAliveIdleS": "TCP keep-alive idle (s)",
+        "masqueWarpDesc": "Conecta ao Cloudflare WARP via MASQUE com uma chave ECDSA P-256 registrada; host e path usam por padrão o endpoint da Cloudflare.",
+        "masqueWarpPublicKey": "Chave pública do endpoint",
+        "masqueWarpAddress": "Endereços do túnel"
       },
       "outbound": {
         "tag": "Tag",

+ 9 - 2
internal/web/translation/ru-RU.json

@@ -719,6 +719,10 @@
           "holeTimeout": "Тайм-аут пропущенного сегмента (мс)",
           "sessionTtl": "TTL сессии (с)",
           "concurrency": "Параллелизм"
+        },
+        "masque": {
+          "addressPool": "Пул адресов",
+          "addressPoolDesc": "Адреса туннелей выдаются из этих префиксов — не более одного IPv4 и одного IPv6."
         }
       },
       "info": {
@@ -813,7 +817,7 @@
       "limitIpFail2banWindows": "Fail2ban недоступен в Windows, поэтому ограничение по IP не может быть применено.",
       "limitIpDisabled": "Функция ограничения по IP отключена на этом сервере.",
       "password": "Пароль",
-      "passwordDesc": "Используется клиентами Trojan, Shadowsocks и TUIC; игнорируется для VLESS, VMess, Hysteria и WireGuard.",
+      "passwordDesc": "Используется клиентами Trojan, Shadowsocks, TUIC и MASQUE; игнорируется для VLESS, VMess, Hysteria и WireGuard.",
       "subId": "ID подписки",
       "subIdDesc": "Также служит кодом приглашения этого клиента в Telegram-боте: тот, кто отправит его боту, будет привязан к этому клиенту. Используйте длинное случайное значение — короткий или легко угадываемый ID может присвоить кто-то другой.",
       "online": "В сети",
@@ -1961,7 +1965,10 @@
         "interface": "Интерфейс",
         "proxyProtocol": "Proxy protocol",
         "tcpUserTimeoutMs": "TCP user timeout (мс)",
-        "tcpKeepAliveIdleS": "TCP keep-alive idle (с)"
+        "tcpKeepAliveIdleS": "TCP keep-alive idle (с)",
+        "masqueWarpDesc": "Подключение к Cloudflare WARP через MASQUE с зарегистрированным ключом ECDSA P-256; host и path по умолчанию указывают на конечную точку Cloudflare.",
+        "masqueWarpPublicKey": "Открытый ключ конечной точки",
+        "masqueWarpAddress": "Адреса туннеля"
       },
       "outbound": {
         "tag": "Тег",

+ 9 - 2
internal/web/translation/tr-TR.json

@@ -718,6 +718,10 @@
           "holeTimeout": "Eksik segment zaman aşımı (ms)",
           "sessionTtl": "Oturum TTL (s)",
           "concurrency": "Eşzamanlılık"
+        },
+        "masque": {
+          "addressPool": "Adres havuzu",
+          "addressPoolDesc": "Tünel adresleri bu öneklerden atanır — en fazla bir IPv4 ve bir IPv6."
         }
       },
       "info": {
@@ -813,7 +817,7 @@
       "limitIpFail2banWindows": "Fail2ban Windows'ta kullanılamadığından IP sınırı uygulanamaz.",
       "limitIpDisabled": "IP sınırı özelliği bu sunucuda devre dışı.",
       "password": "Şifre",
-      "passwordDesc": "Yalnızca Trojan ve Shadowsocks istemcileri tarafından kullanılır; VLESS, VMess, Hysteria ve WireGuard için yok sayılır.",
+      "passwordDesc": "Trojan, Shadowsocks, TUIC ve MASQUE istemcileri tarafından kullanılır; VLESS, VMess, Hysteria ve WireGuard için yok sayılır.",
       "subId": "Abonelik ID'si",
       "subIdDesc": "Bu istemcinin Telegram botu davet kodu olarak da kullanılır: bunu bota gönderen kişi bu istemciye bağlanır. Uzun ve rastgele tutun — kısa veya tahmin edilebilir bir kimlik başkası tarafından sahiplenilebilir.",
       "online": "Çevrimiçi",
@@ -1961,7 +1965,10 @@
         "interface": "Arabirim",
         "proxyProtocol": "Proxy Protocol",
         "tcpUserTimeoutMs": "TCP User Timeout (ms)",
-        "tcpKeepAliveIdleS": "TCP keep-alive bekleme süresi (saniye)"
+        "tcpKeepAliveIdleS": "TCP keep-alive bekleme süresi (saniye)",
+        "masqueWarpDesc": "Kayıtlı bir ECDSA P-256 anahtarıyla MASQUE üzerinden Cloudflare WARP'a bağlanır; host ve path varsayılan olarak Cloudflare uç noktasıdır.",
+        "masqueWarpPublicKey": "Uç nokta açık anahtarı",
+        "masqueWarpAddress": "Tünel adresleri"
       },
       "outbound": {
         "tag": "Etiket",

+ 9 - 2
internal/web/translation/uk-UA.json

@@ -718,6 +718,10 @@
           "holeTimeout": "Тайм-аут пропущеного сегмента (мс)",
           "sessionTtl": "TTL сесії (с)",
           "concurrency": "Паралельність"
+        },
+        "masque": {
+          "addressPool": "Пул адрес",
+          "addressPoolDesc": "Адреси тунелів видаються з цих префіксів — не більше одного IPv4 та одного IPv6."
         }
       },
       "info": {
@@ -813,7 +817,7 @@
       "limitIpFail2banWindows": "Fail2ban недоступний у Windows, тому обмеження за IP не може бути застосоване.",
       "limitIpDisabled": "Функцію обмеження за IP вимкнено на цьому сервері.",
       "password": "Пароль",
-      "passwordDesc": "Використовується лише клієнтами Trojan і Shadowsocks; ігнорується для VLESS, VMess, Hysteria та WireGuard.",
+      "passwordDesc": "Використовується клієнтами Trojan, Shadowsocks, TUIC і MASQUE; ігнорується для VLESS, VMess, Hysteria та WireGuard.",
       "subId": "ID підписки",
       "subIdDesc": "Також слугує кодом запрошення цього клієнта в Telegram-боті: той, хто надішле його боту, буде прив'язаний до цього клієнта. Використовуйте довге випадкове значення — короткий або легко вгадуваний ID може привласнити хтось інший.",
       "online": "У мережі",
@@ -1961,7 +1965,10 @@
         "interface": "Інтерфейс",
         "proxyProtocol": "Proxy protocol",
         "tcpUserTimeoutMs": "TCP user timeout (мс)",
-        "tcpKeepAliveIdleS": "TCP keep-alive idle (с)"
+        "tcpKeepAliveIdleS": "TCP keep-alive idle (с)",
+        "masqueWarpDesc": "Підключення до Cloudflare WARP через MASQUE із зареєстрованим ключем ECDSA P-256; host і path за замовчуванням вказують на кінцеву точку Cloudflare.",
+        "masqueWarpPublicKey": "Відкритий ключ кінцевої точки",
+        "masqueWarpAddress": "Адреси тунелю"
       },
       "outbound": {
         "tag": "Тег",

+ 9 - 2
internal/web/translation/vi-VN.json

@@ -718,6 +718,10 @@
           "holeTimeout": "Thời gian chờ phân đoạn thiếu (ms)",
           "sessionTtl": "TTL phiên (s)",
           "concurrency": "Số luồng đồng thời"
+        },
+        "masque": {
+          "addressPool": "Dải địa chỉ",
+          "addressPoolDesc": "Địa chỉ đường hầm được cấp từ các tiền tố này — tối đa một IPv4 và một IPv6."
         }
       },
       "info": {
@@ -813,7 +817,7 @@
       "limitIpFail2banWindows": "Fail2ban không khả dụng trên Windows nên không thể áp dụng giới hạn IP.",
       "limitIpDisabled": "Tính năng giới hạn IP đã bị tắt trên máy chủ này.",
       "password": "Mật khẩu",
-      "passwordDesc": "Chỉ được dùng bởi các client Trojan và Shadowsocks; bị bỏ qua đối với VLESS, VMess, Hysteria và WireGuard.",
+      "passwordDesc": "Được dùng bởi các client Trojan, Shadowsocks, TUIC và MASQUE; bị bỏ qua đối với VLESS, VMess, Hysteria và WireGuard.",
       "subId": "ID đăng ký",
       "subIdDesc": "Cũng được dùng làm mã mời bot Telegram của client này: ai gửi mã này cho bot sẽ được liên kết với client. Hãy để mã dài và ngẫu nhiên — ID ngắn hoặc dễ đoán có thể bị người khác chiếm.",
       "online": "Trực tuyến",
@@ -1961,7 +1965,10 @@
         "interface": "Giao diện",
         "proxyProtocol": "Proxy protocol",
         "tcpUserTimeoutMs": "TCP user timeout (ms)",
-        "tcpKeepAliveIdleS": "TCP keep-alive idle (s)"
+        "tcpKeepAliveIdleS": "TCP keep-alive idle (s)",
+        "masqueWarpDesc": "Kết nối Cloudflare WARP qua MASQUE bằng khóa ECDSA P-256 đã đăng ký; host và path mặc định là endpoint của Cloudflare.",
+        "masqueWarpPublicKey": "Khóa công khai endpoint",
+        "masqueWarpAddress": "Địa chỉ đường hầm"
       },
       "outbound": {
         "tag": "Tag",

+ 9 - 2
internal/web/translation/zh-CN.json

@@ -718,6 +718,10 @@
           "holeTimeout": "缺失分段超时(毫秒)",
           "sessionTtl": "会话 TTL(秒)",
           "concurrency": "并发数"
+        },
+        "masque": {
+          "addressPool": "地址池",
+          "addressPoolDesc": "隧道地址从这些前缀中分配——最多一个 IPv4 和一个 IPv6。"
         }
       },
       "info": {
@@ -813,7 +817,7 @@
       "limitIpFail2banWindows": "Windows 上不支持 Fail2ban,无法实施 IP 限制。",
       "limitIpDisabled": "此服务器已禁用 IP 限制功能。",
       "password": "密码",
-      "passwordDesc": "用于 Trojan、Shadowsocks 和 TUIC 客户端;对 VLESS、VMess、Hysteria 和 WireGuard 忽略。",
+      "passwordDesc": "用于 Trojan、Shadowsocks、TUIC 和 MASQUE 客户端;对 VLESS、VMess、Hysteria 和 WireGuard 忽略。",
       "subId": "订阅 ID",
       "subIdDesc": "同时用作该客户端的 Telegram 机器人邀请码:任何人将其发送给机器人即会绑定到此客户端。请保持足够长且随机——过短或易猜的 ID 可能被他人抢先绑定。",
       "online": "在线",
@@ -1961,7 +1965,10 @@
         "interface": "接口",
         "proxyProtocol": "Proxy protocol",
         "tcpUserTimeoutMs": "TCP user timeout (ms)",
-        "tcpKeepAliveIdleS": "TCP keep-alive idle (s)"
+        "tcpKeepAliveIdleS": "TCP keep-alive idle (s)",
+        "masqueWarpDesc": "使用已注册的 ECDSA P-256 密钥通过 MASQUE 连接 Cloudflare WARP;host 和 path 默认指向 Cloudflare 端点。",
+        "masqueWarpPublicKey": "端点公钥",
+        "masqueWarpAddress": "隧道地址"
       },
       "outbound": {
         "tag": "标签",

+ 9 - 2
internal/web/translation/zh-TW.json

@@ -718,6 +718,10 @@
           "holeTimeout": "遺失分段逾時(毫秒)",
           "sessionTtl": "工作階段 TTL(秒)",
           "concurrency": "並行數"
+        },
+        "masque": {
+          "addressPool": "位址池",
+          "addressPoolDesc": "通道位址從這些前綴中分配——最多一個 IPv4 與一個 IPv6。"
         }
       },
       "info": {
@@ -813,7 +817,7 @@
       "limitIpFail2banWindows": "Windows 上不支援 Fail2ban,無法實施 IP 限制。",
       "limitIpDisabled": "此伺服器已停用 IP 限制功能。",
       "password": "密碼",
-      "passwordDesc": "僅 Trojan 與 Shadowsocks 用戶端使用;VLESS、VMess、Hysteria 和 WireGuard 會忽略此項。",
+      "passwordDesc": "用於 Trojan、Shadowsocks、TUIC 與 MASQUE 用戶端;VLESS、VMess、Hysteria 和 WireGuard 會忽略此項。",
       "subId": "訂閱 ID",
       "subIdDesc": "同時作為此用戶端的 Telegram 機器人邀請碼:任何人將其傳送給機器人即會綁定到此用戶端。請保持足夠長且隨機——過短或容易猜到的 ID 可能被他人搶先綁定。",
       "online": "上線",
@@ -1961,7 +1965,10 @@
         "interface": "介面",
         "proxyProtocol": "Proxy protocol",
         "tcpUserTimeoutMs": "TCP user timeout (ms)",
-        "tcpKeepAliveIdleS": "TCP keep-alive idle (s)"
+        "tcpKeepAliveIdleS": "TCP keep-alive idle (s)",
+        "masqueWarpDesc": "使用已註冊的 ECDSA P-256 金鑰透過 MASQUE 連線 Cloudflare WARP;host 與 path 預設為 Cloudflare 端點。",
+        "masqueWarpPublicKey": "端點公開金鑰",
+        "masqueWarpAddress": "通道位址"
       },
       "outbound": {
         "tag": "標籤",

+ 19 - 0
internal/xray/api.go

@@ -30,6 +30,7 @@ import (
 	"github.com/xtls/xray-core/common/serial"
 	"github.com/xtls/xray-core/infra/conf"
 	hysteriaAccount "github.com/xtls/xray-core/proxy/hysteria/account"
+	"github.com/xtls/xray-core/proxy/masque"
 	"github.com/xtls/xray-core/proxy/shadowsocks"
 	"github.com/xtls/xray-core/proxy/shadowsocks_2022"
 	"github.com/xtls/xray-core/proxy/trojan"
@@ -677,6 +678,15 @@ func buildUserAccount(protocolName string, user map[string]any) (*serial.TypedMe
 		return serial.ToTypedMessage(&hysteriaAccount.Account{
 			Auth: auth,
 		}), nil
+	case "masque":
+		pass, err := masqueUserPassword(user)
+		if err != nil {
+			return nil, err
+		}
+
+		return serial.ToTypedMessage(&masque.Account{
+			Password: pass,
+		}), nil
 	case "wireguard":
 		pubB64, err := getRequiredUserString(user, "publicKey")
 		if err != nil {
@@ -717,6 +727,15 @@ func buildUserAccount(protocolName string, user map[string]any) (*serial.TypedMe
 	}
 }
 
+// masqueUserPassword reads a MASQUE user's password: panel callers send the client's
+// "password", the hot diff sends the user as emitted for the core, keyed "pass".
+func masqueUserPassword(user map[string]any) (string, error) {
+	if pass, err := getOptionalUserString(user, "pass"); err != nil || pass != "" {
+		return pass, err
+	}
+	return getRequiredUserString(user, "password")
+}
+
 // AddUser adds a user to an inbound in the Xray core using the specified
 // protocol and user data. On a legacy shadowsocks inbound the add first drops
 // any existing holder of the email: that is the one inbound whose validator

+ 43 - 0
internal/xray/api_masque_test.go

@@ -0,0 +1,43 @@
+package xray
+
+import (
+	"testing"
+
+	"github.com/xtls/xray-core/proxy/masque"
+)
+
+// Panel paths hand AddUser the client's "password"; the hot diff hands it the user
+// as GetXrayConfig emitted it, keyed "pass". Without an account the core adds a
+// MASQUE user it can never authenticate.
+func TestBuildUserAccountMasque(t *testing.T) {
+	tests := []struct {
+		name string
+		user map[string]any
+	}{
+		{"panel client", map[string]any{"email": "[email protected]", "password": "pw-ivy"}},
+		{"emitted core user", map[string]any{"email": "[email protected]", "pass": "pw-ivy"}},
+	}
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			typed, err := buildUserAccount("masque", tc.user)
+			if err != nil {
+				t.Fatalf("buildUserAccount: %v", err)
+			}
+			if typed == nil {
+				t.Fatal("buildUserAccount returned no account")
+			}
+			instance, err := typed.GetInstance()
+			if err != nil {
+				t.Fatalf("GetInstance: %v", err)
+			}
+			account, ok := instance.(*masque.Account)
+			if !ok || account.Password != "pw-ivy" {
+				t.Fatalf("account = %#v, want a masque.Account with password pw-ivy", instance)
+			}
+		})
+	}
+
+	if _, err := buildUserAccount("masque", map[string]any{"email": "[email protected]"}); err == nil {
+		t.Fatal("a MASQUE user without a password must be refused, not added unauthenticatable")
+	}
+}

+ 1 - 1
internal/xray/hot_diff.go

@@ -221,7 +221,7 @@ func droppedClients(oldIb, newIb *InboundConfig) []UserOp {
 	return dropped
 }
 
-var userDiffableProtocols = map[string]struct{}{"vless": {}, "vmess": {}, "trojan": {}, "hysteria": {}}
+var userDiffableProtocols = map[string]struct{}{"vless": {}, "vmess": {}, "trojan": {}, "hysteria": {}, "masque": {}}
 
 // diffInboundUsers emits per-user AlterInbound ops when two same-tag inbounds
 // differ only in settings.clients, so the handler (and its listener) survives.

+ 23 - 0
internal/xray/hot_diff_test.go

@@ -211,6 +211,29 @@ func TestComputeHotDiff_ClientOnlyChangeUsesUserOps(t *testing.T) {
 	}
 }
 
+// A MASQUE inbound carries every user's CONNECT-IP tunnel; replacing the handler to
+// add one user would drop all of them, so its client list diffs per user too.
+func TestComputeHotDiff_MasqueClientAddUsesUserOps(t *testing.T) {
+	oldCfg := makeHotConfig()
+	oldCfg.InboundConfigs[1].Protocol = "masque"
+	oldCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[{"email":"a","pass":"pw-a"}],"address":["10.14.0.1/24"]}`)
+	newCfg := makeHotConfig()
+	newCfg.InboundConfigs[1].Protocol = "masque"
+	newCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[{"email":"a","pass":"pw-a"},{"email":"b","pass":"pw-b"}],"address":["10.14.0.1/24"]}`)
+
+	diff, ok := ComputeHotDiff(oldCfg, newCfg)
+	if !ok {
+		t.Fatal("a MASQUE client-only change must be hot-appliable")
+	}
+	if len(diff.RemovedInboundTags) != 0 || len(diff.AddedInbounds) != 0 {
+		t.Fatalf("a MASQUE client add must not replace the handler, got %+v", diff)
+	}
+	if len(diff.RemovedUsers) != 0 || len(diff.AddedUsers) != 1 ||
+		diff.AddedUsers[0].Email != "b" || diff.AddedUsers[0].User["pass"] != "pw-b" {
+		t.Fatalf("expected only user b added with its pass, got added %+v removed %+v", diff.AddedUsers, diff.RemovedUsers)
+	}
+}
+
 func TestComputeHotDiff_ClientChangeFallsBackToReplace(t *testing.T) {
 	cases := []struct {
 		name   string