diff --git a/docs/content/docs/en/config/inbounds.mdx b/docs/content/docs/en/config/inbounds.mdx index 30eeafb1f..919eaf3ec 100644 --- a/docs/content/docs/en/config/inbounds.mdx +++ b/docs/content/docs/en/config/inbounds.mdx @@ -65,6 +65,7 @@ The inbound editor accepts these protocols: | **Dokodemo-door / Tunnel** | Port forwarding / traffic redirect. | | **MTProto** | Telegram MTProto proxy, served by a bundled `mtg` process (not Xray). | | **TUIC** | QUIC-based proxy protocol (v5), served by an in-process native Go server. See [TUIC](/docs/config/tuic). | +| **MASQUE** | CONNECT-IP tunnel over HTTP/3 or HTTP/2, always behind TLS; delivered to clients through the JSON subscription. See [MASQUE](/docs/config/masque). | Hysteria2 isn't a separate protocol internally — it's the `hysteria` protocol diff --git a/docs/content/docs/en/config/masque.mdx b/docs/content/docs/en/config/masque.mdx new file mode 100644 index 000000000..ef7cca0c9 --- /dev/null +++ b/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. | + + + MASQUE has no share-link format, so MASQUE inbounds get no link, QR code or Clash + entry. Clients get a ready-to-import config from the **JSON subscription**: a `masque` + outbound that authenticates with the client's email and password. + + +## Outbound + +Pick **masque** as the outbound protocol, then set: + +| Field | Description | +| ----------------------- | ----------- | +| **Address / Port** | The MASQUE server. | +| **Remote DNS** | Optional DNS server IPs queried inside the tunnel. | +| **Host / Path** | Authority and path of the CONNECT-IP request; the path must match the server's. | +| **Username / Password** | HTTP Basic credentials — on a 3x-ui server, the client's email and password. | +| **Headers** | Extra request headers. | +| **TLS** | Required. An ALPN of `h2` alone switches to HTTP/2 over TCP; otherwise HTTP/3 is used. | + +## WARP over MASQUE + +Turn on **WARP** in the masque transport to reach Cloudflare WARP through its MASQUE +endpoint instead of WireGuard. It takes a WARP registration enrolled for MASQUE: + +| Field | Description | +| ----------------------- | ----------- | +| **Private key** | The enrolled ECDSA P-256 private key (PEM, or base64 DER). | +| **Endpoint public key** | Cloudflare's endpoint public key returned by the enrollment. | +| **Tunnel addresses** | The IPv4 and IPv6 addresses Cloudflare assigned to the registration. | + +Host and path then default to Cloudflare's endpoint, and WARP can't be combined with a +username or password. Point the outbound at the endpoint address from your enrollment and +set the TLS server name to `consumer-masque.cloudflareclient.com`. diff --git a/docs/content/docs/en/config/meta.json b/docs/content/docs/en/config/meta.json index e11e806f2..c6de02cc0 100644 --- a/docs/content/docs/en/config/meta.json +++ b/docs/content/docs/en/config/meta.json @@ -8,6 +8,7 @@ "reality", "amneziawg", "tuic", + "masque", "transports", "clients", "subscription", diff --git a/docs/content/docs/en/config/transports.mdx b/docs/content/docs/en/config/transports.mdx index f5baf7c91..6033aa7fa 100644 --- a/docs/content/docs/en/config/transports.mdx +++ b/docs/content/docs/en/config/transports.mdx @@ -1,6 +1,6 @@ --- title: Transports & Security -description: Every transport 3x-ui exposes — TCP, mKCP, WebSocket, gRPC, HTTPUpgrade, XHTTP, Hysteria, 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). | **WireGuard** and **Tunnel** (dokodemo-door) inbounds expose no transport diff --git a/docs/content/docs/fa/config/inbounds.mdx b/docs/content/docs/fa/config/inbounds.mdx index 1c1d2f710..d8481eca0 100644 --- a/docs/content/docs/fa/config/inbounds.mdx +++ b/docs/content/docs/fa/config/inbounds.mdx @@ -65,6 +65,7 @@ TLS یا REALITY) را انتخاب کنید. به [انتقال‌ها](/docs/c | **Dokodemo-door / Tunnel** | فورواردینگ پورت / هدایت ترافیک. | | **MTProto** | پراکسی MTProto تلگرام که توسط یک فرایند همراه `mtg` سرویس می‌شود (نه Xray). | | **TUIC** | پروتکل پراکسی مبتنی بر QUIC نسخه ۵ که به صورت سرور بومی Go درون فرایند ارائه می‌شود. مشاهده [TUIC](/docs/config/tuic). | +| **MASQUE** | تونل CONNECT-IP روی HTTP/3 یا HTTP/2، همیشه پشت TLS؛ از طریق اشتراک JSON به کلاینت‌ها تحویل داده می‌شود. مشاهده [MASQUE](/docs/config/masque). | Hysteria2 در سطح داخلی یک پروتکل جداگانه نیست — همان پروتکل `hysteria` است که diff --git a/docs/content/docs/fa/config/masque.mdx b/docs/content/docs/fa/config/masque.mdx new file mode 100644 index 000000000..01dbdcb52 --- /dev/null +++ b/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 را سرو می‌کند و هر دو با هم، هر دو را. | + + + MASQUE فرمت لینک اشتراک‌گذاری ندارد، بنابراین inboundهای MASQUE لینک، کد QR یا ورودی + Clash ندارند. کلاینت‌ها کانفیگ آمادهٔ import را از **اشتراک JSON** می‌گیرند: یک outbound + از نوع `masque` که با ایمیل و رمز عبور کلاینت احراز هویت می‌کند. + + +## Outbound + +**masque** را به‌عنوان پروتکل outbound انتخاب کنید و سپس تنظیم کنید: + +| فیلد | توضیح | +| -------------------------- | ----- | +| **آدرس / پورت** | سرور MASQUE. | +| **Remote DNS** | IPهای اختیاری سرور DNS که داخل تونل پرس‌وجو می‌شوند. | +| **Host / Path** | authority و مسیر درخواست CONNECT-IP؛ مسیر باید با مسیر سرور یکسان باشد. | +| **نام کاربری / رمز عبور** | اعتبارنامهٔ HTTP Basic — روی سرور 3x-ui، همان ایمیل و رمز عبور کلاینت. | +| **Headers** | هدرهای اضافی درخواست. | +| **TLS** | الزامی. ALPN فقط `h2` اتصال را به HTTP/2 روی TCP می‌برد؛ در غیر این صورت HTTP/3 استفاده می‌شود. | + +## WARP از طریق MASQUE + +**WARP** را در transport مربوط به masque روشن کنید تا به‌جای WireGuard از طریق endpoint +مخصوص MASQUE به Cloudflare WARP برسید. این کار به یک ثبت‌نام WARP نیاز دارد که برای MASQUE +ثبت شده باشد: + +| فیلد | توضیح | +| ------------------------ | ----- | +| **کلید خصوصی** | کلید خصوصی ECDSA P-256 ثبت‌شده (PEM یا DER با base64). | +| **کلید عمومی endpoint** | کلید عمومی endpoint کلودفلر که در ثبت‌نام برگردانده می‌شود. | +| **آدرس‌های تونل** | آدرس‌های IPv4 و IPv6 که کلودفلر به این ثبت‌نام اختصاص داده است. | + +در این حالت host و path به‌طور پیش‌فرض endpoint کلودفلر هستند و WARP را نمی‌توان با نام +کاربری یا رمز عبور ترکیب کرد. outbound را به آدرس endpoint حاصل از ثبت‌نام خود هدایت کنید و +نام سرور TLS را `consumer-masque.cloudflareclient.com` قرار دهید. diff --git a/docs/content/docs/fa/config/meta.json b/docs/content/docs/fa/config/meta.json index 59fa5ff1a..226febab0 100644 --- a/docs/content/docs/fa/config/meta.json +++ b/docs/content/docs/fa/config/meta.json @@ -6,6 +6,7 @@ "ssl-certificates", "inbounds", "reality", + "masque", "transports", "clients", "subscription", diff --git a/docs/content/docs/fa/config/transports.mdx b/docs/content/docs/fa/config/transports.mdx index 2690a269d..f87f9eda8 100644 --- a/docs/content/docs/fa/config/transports.mdx +++ b/docs/content/docs/fa/config/transports.mdx @@ -1,6 +1,6 @@ --- title: انتقال‌ها و امنیت -description: هر انتقالی که 3x-ui ارائه می‌دهد — TCP، mKCP، WebSocket، gRPC، HTTPUpgrade، XHTTP، Hysteria، 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). | inbound‌های **WireGuard** و **Tunnel** (dokodemo-door) هیچ انتخابگر انتقالی diff --git a/docs/content/docs/ru/config/inbounds.mdx b/docs/content/docs/ru/config/inbounds.mdx index 763c76ba5..7ba414240 100644 --- a/docs/content/docs/ru/config/inbounds.mdx +++ b/docs/content/docs/ru/config/inbounds.mdx @@ -66,6 +66,7 @@ icon: ArrowDownToLine | **Dokodemo-door / Tunnel** | Перенаправление портов / перенаправление трафика. | | **MTProto** | Прокси Telegram MTProto, обслуживаемый встроенным процессом `mtg` (не Xray). | | **TUIC** | Протокол проксирования на базе QUIC (v5), обслуживаемый встроенным сервером на Go. См. [TUIC](/docs/config/tuic). | +| **MASQUE** | Туннель CONNECT-IP поверх HTTP/3 или HTTP/2, всегда за TLS; клиенты получают его через JSON-подписку. См. [MASQUE](/docs/config/masque). | Hysteria2 внутренне не является отдельным протоколом — это протокол `hysteria` diff --git a/docs/content/docs/ru/config/masque.mdx b/docs/content/docs/ru/config/masque.mdx new file mode 100644 index 000000000..7a5f2519c --- /dev/null +++ b/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, вместе — оба. | + + + У MASQUE нет формата ссылок, поэтому для инбаундов MASQUE не создаются ссылка, QR-код и + запись Clash. Клиенты получают готовую к импорту конфигурацию из **JSON-подписки**: + аутбаунд `masque`, который аутентифицируется email и паролем клиента. + + +## Аутбаунд + +Выберите протокол аутбаунда **masque** и задайте: + +| Поле | Описание | +| -------------------------- | -------- | +| **Адрес / Порт** | Сервер MASQUE. | +| **Remote DNS** | Необязательные IP DNS-серверов, к которым идут запросы внутри туннеля. | +| **Host / Path** | Authority и путь запроса CONNECT-IP; путь должен совпадать с путём сервера. | +| **Имя пользователя / Пароль** | Учётные данные HTTP Basic — на сервере 3x-ui это email и пароль клиента. | +| **Headers** | Дополнительные заголовки запроса. | +| **TLS** | Обязательно. ALPN только с `h2` переключает на HTTP/2 по TCP; иначе используется HTTP/3. | + +## WARP через MASQUE + +Включите **WARP** в транспорте masque, чтобы подключаться к Cloudflare WARP через его +MASQUE-эндпоинт вместо WireGuard. Для этого нужна регистрация WARP, привязанная к MASQUE: + +| Поле | Описание | +| -------------------------- | -------- | +| **Закрытый ключ** | Зарегистрированный закрытый ключ ECDSA P-256 (PEM или DER в base64). | +| **Открытый ключ эндпоинта** | Открытый ключ эндпоинта Cloudflare, возвращённый при регистрации. | +| **Адреса туннеля** | Адреса IPv4 и IPv6, которые Cloudflare назначил регистрации. | + +В этом случае host и path по умолчанию указывают на эндпоинт Cloudflare, а WARP нельзя +сочетать с именем пользователя или паролем. Направьте аутбаунд на адрес эндпоинта из вашей +регистрации и укажите имя сервера TLS `consumer-masque.cloudflareclient.com`. diff --git a/docs/content/docs/ru/config/meta.json b/docs/content/docs/ru/config/meta.json index 6084601c1..35c0c98f8 100644 --- a/docs/content/docs/ru/config/meta.json +++ b/docs/content/docs/ru/config/meta.json @@ -7,6 +7,7 @@ "inbounds", "reality", "tuic", + "masque", "transports", "clients", "subscription", diff --git a/docs/content/docs/ru/config/transports.mdx b/docs/content/docs/ru/config/transports.mdx index ccdbe2e11..72771a9f1 100644 --- a/docs/content/docs/ru/config/transports.mdx +++ b/docs/content/docs/ru/config/transports.mdx @@ -1,6 +1,6 @@ --- title: Транспорты и безопасность -description: Все транспорты, которые предоставляет 3x-ui — TCP, mKCP, WebSocket, gRPC, HTTPUpgrade, XHTTP, Hysteria, 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). | Inbound-соединения **WireGuard** и **Tunnel** (dokodemo-door) не предоставляют diff --git a/docs/content/docs/zh/config/inbounds.mdx b/docs/content/docs/zh/config/inbounds.mdx index 5c05fdd30..0f3c0837f 100644 --- a/docs/content/docs/zh/config/inbounds.mdx +++ b/docs/content/docs/zh/config/inbounds.mdx @@ -62,6 +62,7 @@ icon: ArrowDownToLine | **Dokodemo-door / Tunnel** | 端口转发 / 流量重定向。 | | **MTProto** | Telegram MTProto 代理,由内置的 `mtg` 进程提供(而非 Xray)。 | | **TUIC** | 基于 QUIC 的代理协议(v5),由进程内原生 Go 服务器提供。参见 [TUIC](/docs/config/tuic)。 | +| **MASQUE** | 基于 HTTP/3 或 HTTP/2 的 CONNECT-IP 隧道,始终使用 TLS;通过 JSON 订阅下发给客户端。参见 [MASQUE](/docs/config/masque)。 | 在内部,Hysteria2 并不是一个独立的协议——它是把传输版本设为 2 的 `hysteria` diff --git a/docs/content/docs/zh/config/masque.mdx b/docs/content/docs/zh/config/masque.mdx new file mode 100644 index 000000000..2316f1af5 --- /dev/null +++ b/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,两者同时设置则都提供。 | + + + MASQUE 没有分享链接格式,因此 MASQUE 入站不生成链接、二维码或 Clash 条目。客户端通过 **JSON 订阅**获取可直接导入的配置: + 一个使用该客户端邮箱和密码认证的 `masque` 出站。 + + +## 出站 + +选择 **masque** 作为出站协议,然后设置: + +| 字段 | 说明 | +| ------------------- | ---- | +| **地址 / 端口** | MASQUE 服务器。 | +| **Remote DNS** | 可选,在隧道内查询的 DNS 服务器 IP。 | +| **Host / Path** | CONNECT-IP 请求的 authority 和路径;路径必须与服务端一致。 | +| **用户名 / 密码** | HTTP Basic 凭据——在 3x-ui 服务端上即客户端的邮箱和密码。 | +| **Headers** | 额外的请求头。 | +| **TLS** | 必需。ALPN 仅为 `h2` 时改用 TCP 上的 HTTP/2;否则使用 HTTP/3。 | + +## 通过 MASQUE 使用 WARP + +在 masque 传输中开启 **WARP**,即可通过 Cloudflare 的 MASQUE 端点而非 WireGuard 连接 WARP。这需要一个已为 +MASQUE 注册的 WARP 账户: + +| 字段 | 说明 | +| ---------------- | ---- | +| **私钥** | 已注册的 ECDSA P-256 私钥(PEM 或 base64 编码的 DER)。 | +| **端点公钥** | 注册时返回的 Cloudflare 端点公钥。 | +| **隧道地址** | Cloudflare 为该注册分配的 IPv4 和 IPv6 地址。 | + +此时 host 和 path 默认指向 Cloudflare 端点,且 WARP 不能与用户名或密码同时使用。请将出站指向注册所得的端点地址, +并将 TLS 服务器名称设为 `consumer-masque.cloudflareclient.com`。 diff --git a/docs/content/docs/zh/config/meta.json b/docs/content/docs/zh/config/meta.json index e61c44b83..63f65bd77 100644 --- a/docs/content/docs/zh/config/meta.json +++ b/docs/content/docs/zh/config/meta.json @@ -6,6 +6,7 @@ "ssl-certificates", "inbounds", "reality", + "masque", "transports", "clients", "subscription", diff --git a/docs/content/docs/zh/config/transports.mdx b/docs/content/docs/zh/config/transports.mdx index e87cf5dec..3326d712e 100644 --- a/docs/content/docs/zh/config/transports.mdx +++ b/docs/content/docs/zh/config/transports.mdx @@ -1,6 +1,6 @@ --- title: 传输方式与安全层 -description: 3x-ui 提供的每一种传输方式——TCP、mKCP、WebSocket、gRPC、HTTPUpgrade、XHTTP、Hysteria、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)。 | **WireGuard** 和 **Tunnel**(dokodemo-door)入站不提供传输方式选择器——它们的传输流 diff --git a/frontend/src/components/command-palette/CommandPalette.tsx b/frontend/src/components/command-palette/CommandPalette.tsx index 5861356ff..5ee263ce9 100644 --- a/frontend/src/components/command-palette/CommandPalette.tsx +++ b/frontend/src/components/command-palette/CommandPalette.tsx @@ -363,6 +363,7 @@ export default function CommandPalette() { 'shadowsocks', 'wireguard', 'hysteria', + 'masque', ], icon: , }, diff --git a/frontend/src/lib/xray/inbound-defaults.ts b/frontend/src/lib/xray/inbound-defaults.ts index 1dcb7708b..43094bb13 100644 --- a/frontend/src/lib/xray/inbound-defaults.ts +++ b/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; } diff --git a/frontend/src/lib/xray/inbound-form-adapter.ts b/frontend/src/lib/xray/inbound-form-adapter.ts index 3f181b47a..29785f49f 100644 --- a/frontend/src/lib/xray/inbound-form-adapter.ts +++ b/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 = { httpupgrade: 'httpupgradeSettings', xhttp: 'xhttpSettings', hysteria: 'hysteriaSettings', + xdrive: 'xdriveSettings', + masque: 'masqueSettings', }; function healStreamNetworkKey(stream: Record): 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; } diff --git a/frontend/src/lib/xray/inbound-tag.ts b/frontend/src/lib/xray/inbound-tag.ts index a51628474..816d52093 100644 --- a/frontend/src/lib/xray/inbound-tag.ts +++ b/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 | 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'; diff --git a/frontend/src/lib/xray/inbound-tls-defaults.ts b/frontend/src/lib/xray/inbound-tls-defaults.ts index 3861e3fc9..d78d6c2db 100644 --- a/frontend/src/lib/xray/inbound-tls-defaults.ts +++ b/frontend/src/lib/xray/inbound-tls-defaults.ts @@ -31,6 +31,11 @@ export function createTlsSettingsWithDefaultCert(network?: string): Record { + return createHysteriaTlsSettingsWithDefaultCert(); +} + export function createHysteriaTlsSettingsWithDefaultCert(): Record { const tls = createTlsSettingsWithDefaultCert(); tls.alpn = ['h3']; diff --git a/frontend/src/lib/xray/node-protocols.ts b/frontend/src/lib/xray/node-protocols.ts index 23d3420be..7e3c02221 100644 --- a/frontend/src/lib/xray/node-protocols.ts +++ b/frontend/src/lib/xray/node-protocols.ts @@ -17,4 +17,5 @@ export const NODE_ELIGIBLE_PROTOCOLS: Readonly> = { [Protocols.MTPROTO]: true, [Protocols.AMNEZIAWG]: true, [Protocols.TUIC]: true, + [Protocols.MASQUE]: true, }; diff --git a/frontend/src/lib/xray/outbound-defaults.ts b/frontend/src/lib/xray/outbound-defaults.ts index ddf55e898..fb6877e3d 100644 --- a/frontend/src/lib/xray/outbound-defaults.ts +++ b/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: diff --git a/frontend/src/lib/xray/outbound-form-adapter.ts b/frontend/src/lib/xray/outbound-form-adapter.ts index dc59dac39..4c60ebde3 100644 --- a/frontend/src/lib/xray/outbound-form-adapter.ts +++ b/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; diff --git a/frontend/src/lib/xray/protocol-capabilities.ts b/frontend/src/lib/xray/protocol-capabilities.ts index cc9c61e33..be6d76ce6 100644 --- a/frontend/src/lib/xray/protocol-capabilities.ts +++ b/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 ?? ''); } diff --git a/frontend/src/pages/clients/BulkAttachInboundsModal.tsx b/frontend/src/pages/clients/BulkAttachInboundsModal.tsx index cbb0d1924..845b340be 100644 --- a/frontend/src/pages/clients/BulkAttachInboundsModal.tsx +++ b/frontend/src/pages/clients/BulkAttachInboundsModal.tsx @@ -17,6 +17,7 @@ const MULTI_USER_PROTOCOLS = new Set([ 'mtproto', 'amneziawg', 'tuic', + 'masque', ]); interface BulkAttachInboundsModalProps { diff --git a/frontend/src/pages/clients/BulkDetachInboundsModal.tsx b/frontend/src/pages/clients/BulkDetachInboundsModal.tsx index 6a0fd3e5e..42985624c 100644 --- a/frontend/src/pages/clients/BulkDetachInboundsModal.tsx +++ b/frontend/src/pages/clients/BulkDetachInboundsModal.tsx @@ -17,6 +17,7 @@ const MULTI_USER_PROTOCOLS = new Set([ 'mtproto', 'amneziawg', 'tuic', + 'masque', ]); interface BulkDetachInboundsModalProps { diff --git a/frontend/src/pages/clients/ClientBulkAddModal.tsx b/frontend/src/pages/clients/ClientBulkAddModal.tsx index 2c692a6c2..c915c7c9f 100644 --- a/frontend/src/pages/clients/ClientBulkAddModal.tsx +++ b/frontend/src/pages/clients/ClientBulkAddModal.tsx @@ -39,6 +39,7 @@ const MULTI_CLIENT_PROTOCOLS = new Set([ 'wireguard', 'amneziawg', 'tuic', + 'masque', ]); const EMPTY: ClientBulkAddFormValues = { diff --git a/frontend/src/pages/clients/ClientFormModal.tsx b/frontend/src/pages/clients/ClientFormModal.tsx index 7f785b36b..8de2c3b7d 100644 --- a/frontend/src/pages/clients/ClientFormModal.tsx +++ b/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; diff --git a/frontend/src/pages/hosts/HostList.tsx b/frontend/src/pages/hosts/HostList.tsx index 5e6a5251b..77301ca43 100644 --- a/frontend/src/pages/hosts/HostList.tsx +++ b/frontend/src/pages/hosts/HostList.tsx @@ -44,6 +44,7 @@ const INBOUND_PROTOCOL_COLORS: Record = { mixed: 'lime', tunnel: 'orange', tuic: 'orange', + masque: 'red', }; export function sortHosts(hosts: HostRecord[]): HostRecord[] { diff --git a/frontend/src/pages/inbounds/InboundsPage.tsx b/frontend/src/pages/inbounds/InboundsPage.tsx index a2bd25896..13683f40d 100644 --- a/frontend/src/pages/inbounds/InboundsPage.tsx +++ b/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; diff --git a/frontend/src/pages/inbounds/form/InboundFormModal.tsx b/frontend/src/pages/inbounds/form/InboundFormModal.tsx index ae0fa59cb..4563d97d2 100644 --- a/frontend/src/pages/inbounds/form/InboundFormModal.tsx +++ b/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 && } + {protocol === Protocols.MASQUE && } + {protocol === Protocols.TUN && } {protocol === Protocols.TUNNEL && } @@ -943,6 +959,8 @@ export default function InboundFormModal({ dropdown is hidden above. */} {protocol === Protocols.HYSTERIA && } + {protocol === Protocols.MASQUE && } + {hasSelectableTransport && ( <> {network === 'tcp' && } @@ -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 ? [ diff --git a/frontend/src/pages/inbounds/form/protocols/index.ts b/frontend/src/pages/inbounds/form/protocols/index.ts index cdbd41bef..115484c76 100644 --- a/frontend/src/pages/inbounds/form/protocols/index.ts +++ b/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'; diff --git a/frontend/src/pages/inbounds/form/protocols/masque.tsx b/frontend/src/pages/inbounds/form/protocols/masque.tsx new file mode 100644 index 000000000..a5d5a4e27 --- /dev/null +++ b/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 ( + <> + + + + ); +} diff --git a/frontend/src/pages/inbounds/list/helpers.ts b/frontend/src/pages/inbounds/list/helpers.ts index 0c0144b25..785c74733 100644 --- a/frontend/src/pages/inbounds/list/helpers.ts +++ b/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) }); diff --git a/frontend/src/pages/inbounds/useInbounds.ts b/frontend/src/pages/inbounds/useInbounds.ts index b1e2b3725..5e8caa91c 100644 --- a/frontend/src/pages/inbounds/useInbounds.ts +++ b/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 { diff --git a/frontend/src/pages/settings/SubBalancerFormModal.tsx b/frontend/src/pages/settings/SubBalancerFormModal.tsx index 9c81d684f..edef85a1c 100644 --- a/frontend/src/pages/settings/SubBalancerFormModal.tsx +++ b/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 = { diff --git a/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx b/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx index c5bea48b9..d02f75c05 100644 --- a/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx +++ b/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; + 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' && } {protocol === 'http' && } {protocol === 'socks' && } + {protocol === 'masque' && } {protocol === 'loopback' && } {protocol === 'blackhole' && } @@ -470,7 +488,9 @@ export default function OutboundFormModal({ options={ protocol === 'hysteria' ? [HYSTERIA_NETWORK_OPTION] - : NETWORK_OPTIONS + : protocol === 'masque' + ? [MASQUE_NETWORK_OPTION] + : NETWORK_OPTIONS } /> @@ -490,6 +510,8 @@ export default function OutboundFormModal({ {network === 'hysteria' && } {network === 'xdrive' && } + + {network === 'masque' && } )} @@ -532,7 +554,7 @@ export default function OutboundFormModal({ buttonStyle="solid" onChange={(e) => onSecurityChange(e.target.value as string)} > - {network !== 'hysteria' && ( + {network !== 'hysteria' && network !== 'masque' && ( {t('none')} )} {tlsAllowed && TLS} diff --git a/frontend/src/pages/xray/outbounds/outbound-form-constants.ts b/frontend/src/pages/xray/outbounds/outbound-form-constants.ts index 50ba9eaeb..60c63093c 100644 --- a/frontend/src/pages/xray/outbounds/outbound-form-constants.ts +++ b/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([ 'socks', 'http', 'hysteria', + 'masque', ]); diff --git a/frontend/src/pages/xray/outbounds/outbound-form-helpers.ts b/frontend/src/pages/xray/outbounds/outbound-form-helpers.ts index a4a145e58..5f3b1f2fa 100644 --- a/frontend/src/pages/xray/outbounds/outbound-form-helpers.ts +++ b/frontend/src/pages/xray/outbounds/outbound-form-helpers.ts @@ -67,6 +67,8 @@ export function newStreamSlice(network: string): Record { }; 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 { }; } +// 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 { + 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 { if (next === 'hysteria') return hysteriaStreamSlice(); + if (next === 'masque') return masqueStreamSlice(); const stream = prevStream ?? {}; const currentSecurity = (stream.security as string) ?? 'none'; const stillTls = canEnableTls({ diff --git a/frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts b/frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts index 27bd0ba5a..bcaca321d 100644 --- a/frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts +++ b/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); diff --git a/frontend/src/pages/xray/outbounds/protocols/index.ts b/frontend/src/pages/xray/outbounds/protocols/index.ts index e0fb4f279..7f55ee9d0 100644 --- a/frontend/src/pages/xray/outbounds/protocols/index.ts +++ b/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'; diff --git a/frontend/src/pages/xray/outbounds/protocols/masque.tsx b/frontend/src/pages/xray/outbounds/protocols/masque.tsx new file mode 100644 index 000000000..cf7edc9ac --- /dev/null +++ b/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 ( + + + + + + + + { + setValue( + 'streamSettings.masqueSettings.warp', + on ? { privateKey: '', publicKey: '', address: [] } : undefined, + ); + if (on) { + setValue('streamSettings.masqueSettings.user', undefined); + setValue('streamSettings.masqueSettings.pass', undefined); + } + }} + /> + + {warpOn ? ( + <> + + + + + + + + + + + + + + )} + + + + + ); +} diff --git a/frontend/src/schemas/forms/outbound-form.ts b/frontend/src/schemas/forms/outbound-form.ts index 81aec8906..535d0d74c 100644 --- a/frontend/src/schemas/forms/outbound-form.ts +++ b/frontend/src/schemas/forms/outbound-form.ts @@ -125,6 +125,13 @@ export const HysteriaOutboundFormSettingsSchema = z.object({ }); export type HysteriaOutboundFormSettings = z.infer; +export const MasqueOutboundFormSettingsSchema = z.object({ + address: z.string().default(''), + port: PortSchema.default(443), + remoteDNS: z.array(z.string()).default([]), +}); +export type MasqueOutboundFormSettings = z.infer; + // FinalRule (freedom): network/port are strings; ip is string[]; blockDelay // is only meaningful when action === 'block'. The adapter omits empty // fields from the wire payload. @@ -200,6 +207,7 @@ export const OutboundFormSettingsSchema = z.discriminatedUnion('protocol', [ z.object({ protocol: z.literal('wireguard'), settings: WireguardOutboundFormSettingsSchema }), z.object({ protocol: z.literal('amneziawg'), settings: AmneziaWGOutboundFormSettingsSchema }), z.object({ protocol: z.literal('hysteria'), settings: HysteriaOutboundFormSettingsSchema }), + z.object({ protocol: z.literal('masque'), settings: MasqueOutboundFormSettingsSchema }), z.object({ protocol: z.literal('freedom'), settings: FreedomOutboundFormSettingsSchema }), z.object({ protocol: z.literal('blackhole'), settings: BlackholeOutboundFormSettingsSchema }), z.object({ protocol: z.literal('dns'), settings: DnsOutboundFormSettingsSchema }), diff --git a/frontend/src/schemas/primitives/outbound-protocol.ts b/frontend/src/schemas/primitives/outbound-protocol.ts index 34c60414a..a835aeee1 100644 --- a/frontend/src/schemas/primitives/outbound-protocol.ts +++ b/frontend/src/schemas/primitives/outbound-protocol.ts @@ -19,6 +19,7 @@ export const OutboundProtocols = Object.freeze({ Wireguard: 'wireguard', AmneziaWG: 'amneziawg', Hysteria: 'hysteria', + Masque: 'masque', Socks: 'socks', HTTP: 'http', Loopback: 'loopback', diff --git a/frontend/src/schemas/primitives/protocol.ts b/frontend/src/schemas/primitives/protocol.ts index 461286614..0f813476f 100644 --- a/frontend/src/schemas/primitives/protocol.ts +++ b/frontend/src/schemas/primitives/protocol.ts @@ -14,6 +14,7 @@ export const ProtocolSchema = z.enum([ 'mtproto', 'amneziawg', 'tuic', + 'masque', ]); export type Protocol = z.infer; @@ -37,4 +38,5 @@ export const Protocols = Object.freeze({ MTPROTO: 'mtproto', AMNEZIAWG: 'amneziawg', TUIC: 'tuic', + MASQUE: 'masque', }); diff --git a/frontend/src/schemas/protocols/inbound/index.ts b/frontend/src/schemas/protocols/inbound/index.ts index c95cecf0d..16d63a575 100644 --- a/frontend/src/schemas/protocols/inbound/index.ts +++ b/frontend/src/schemas/protocols/inbound/index.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { AmneziawgInboundSettingsSchema } from './amneziawg'; import { HttpInboundSettingsSchema } from './http'; import { HysteriaInboundSettingsSchema } from './hysteria'; +import { MasqueInboundSettingsSchema } from './masque'; import { MixedInboundSettingsSchema } from './mixed'; import { MtprotoInboundSettingsSchema } from './mtproto'; import { ShadowsocksInboundSettingsSchema } from './shadowsocks'; @@ -17,6 +18,7 @@ import { WireguardInboundSettingsSchema } from './wireguard'; export * from './amneziawg'; export * from './http'; export * from './hysteria'; +export * from './masque'; export * from './mixed'; export * from './mtproto'; export * from './shadowsocks'; @@ -47,5 +49,6 @@ export const InboundSettingsSchema = z.discriminatedUnion('protocol', [ z.object({ protocol: z.literal('mtproto'), settings: MtprotoInboundSettingsSchema }), z.object({ protocol: z.literal('amneziawg'), settings: AmneziawgInboundSettingsSchema }), z.object({ protocol: z.literal('tuic'), settings: TuicInboundSettingsSchema }), + z.object({ protocol: z.literal('masque'), settings: MasqueInboundSettingsSchema }), ]); export type InboundSettings = z.infer; diff --git a/frontend/src/schemas/protocols/inbound/masque.ts b/frontend/src/schemas/protocols/inbound/masque.ts new file mode 100644 index 000000000..5de9b1a98 --- /dev/null +++ b/frontend/src/schemas/protocols/inbound/masque.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; + +// MASQUE inbound (CONNECT-IP over HTTP/3 or HTTP/2). Clients log in with their email +// and password over Basic auth; the panel stores `password` and the backend hands it +// to xray-core as `pass`. +export const MasqueClientSchema = z.object({ + password: z.string().min(1), + email: z.string().min(1), + limitIp: z.number().int().min(0).default(0), + totalGB: z.number().int().min(0).default(0), + expiryTime: z.number().int().default(0), + enable: z.boolean().default(true), + tgId: z + .union([z.number(), z.string()]) + .transform((v) => Number(v) || 0) + .default(0), + subId: z.string().default(''), + comment: z.string().default(''), + reset: z.number().int().min(0).default(0), + created_at: z.number().int().optional(), + updated_at: z.number().int().optional(), +}); +export type MasqueClient = z.infer; + +export const MASQUE_DEFAULT_ADDRESS_POOL = ['10.14.0.1/24', 'fd14::1/64']; + +export const MasqueInboundSettingsSchema = z.object({ + clients: z.array(MasqueClientSchema).default([]), + // Tunnel addresses are leased from these prefixes: at most one IPv4 and one IPv6. + address: z.array(z.string()).default(MASQUE_DEFAULT_ADDRESS_POOL), + mtu: z.number().int().min(1280).max(65535).optional(), +}); +export type MasqueInboundSettings = z.infer; diff --git a/frontend/src/schemas/protocols/outbound/index.ts b/frontend/src/schemas/protocols/outbound/index.ts index bb9f470c5..8e10272d0 100644 --- a/frontend/src/schemas/protocols/outbound/index.ts +++ b/frontend/src/schemas/protocols/outbound/index.ts @@ -6,6 +6,7 @@ import { DNSOutboundSettingsSchema } from './dns'; import { FreedomOutboundSettingsSchema } from './freedom'; import { HttpOutboundSettingsSchema } from './http'; import { HysteriaOutboundSettingsSchema } from './hysteria'; +import { MasqueOutboundSettingsSchema } from './masque'; import { LoopbackOutboundSettingsSchema } from './loopback'; import { ShadowsocksOutboundSettingsSchema } from './shadowsocks'; import { SocksOutboundSettingsSchema } from './socks'; @@ -20,6 +21,7 @@ export * from './dns'; export * from './freedom'; export * from './http'; export * from './hysteria'; +export * from './masque'; export * from './loopback'; export * from './shadowsocks'; export * from './socks'; @@ -36,6 +38,7 @@ export const OutboundSettingsSchema = z.discriminatedUnion('protocol', [ z.object({ protocol: z.literal('wireguard'), settings: WireguardOutboundSettingsSchema }), z.object({ protocol: z.literal('amneziawg'), settings: AmneziaWGOutboundSettingsSchema }), z.object({ protocol: z.literal('hysteria'), settings: HysteriaOutboundSettingsSchema }), + z.object({ protocol: z.literal('masque'), settings: MasqueOutboundSettingsSchema }), z.object({ protocol: z.literal('http'), settings: HttpOutboundSettingsSchema }), z.object({ protocol: z.literal('socks'), settings: SocksOutboundSettingsSchema }), z.object({ protocol: z.literal('freedom'), settings: FreedomOutboundSettingsSchema }), diff --git a/frontend/src/schemas/protocols/outbound/masque.ts b/frontend/src/schemas/protocols/outbound/masque.ts new file mode 100644 index 000000000..9abbba5e2 --- /dev/null +++ b/frontend/src/schemas/protocols/outbound/masque.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +import { PortSchema } from '@/schemas/primitives'; + +// MASQUE outbound names its server only; credentials, path and WARP ride on the +// masque transport. remoteDNS lists IPs queried inside the CONNECT-IP tunnel. +export const MasqueOutboundSettingsSchema = z.object({ + address: z.string().min(1), + port: PortSchema, + remoteDNS: z.array(z.string()).optional(), +}); +export type MasqueOutboundSettings = z.infer; diff --git a/frontend/src/schemas/protocols/stream/index.ts b/frontend/src/schemas/protocols/stream/index.ts index 5a6adeb85..5c0cfb69a 100644 --- a/frontend/src/schemas/protocols/stream/index.ts +++ b/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; @@ -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'), diff --git a/frontend/src/schemas/protocols/stream/masque.ts b/frontend/src/schemas/protocols/stream/masque.ts new file mode 100644 index 000000000..b19eb5c84 --- /dev/null +++ b/frontend/src/schemas/protocols/stream/masque.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; + +// The path xray-core's MASQUE transport uses when none is set; the server matches +// a request on it after expanding {target}/{ipproto} to "*". +export const MASQUE_DEFAULT_PATH = '/.well-known/masque/ip/*/*/'; + +// WARP over MASQUE: an enrolled ECDSA P-256 key (PEM or base64 DER), the endpoint's +// public key, and the tunnel addresses Cloudflare assigned (one IPv4, one IPv6). +export const MasqueWarpSchema = z.object({ + privateKey: z.string().default(''), + publicKey: z.string().default(''), + address: z.array(z.string()).default([]), +}); +export type MasqueWarp = z.infer; + +// A server reads only `path`; host, user/pass (Basic auth), headers and warp are the +// client side. Every key is optional so a stored stream round-trips byte-stably. +export const MasqueStreamSettingsSchema = z.object({ + host: z.string().optional(), + path: z.string().optional(), + user: z.string().optional(), + pass: z.string().optional(), + headers: z.record(z.string(), z.string()).optional(), + warp: MasqueWarpSchema.optional(), +}); +export type MasqueStreamSettings = z.infer; diff --git a/frontend/src/test/__snapshots__/inbound-full.test.ts.snap b/frontend/src/test/__snapshots__/inbound-full.test.ts.snap index 453873ed0..0377c41d8 100644 --- a/frontend/src/test/__snapshots__/inbound-full.test.ts.snap +++ b/frontend/src/test/__snapshots__/inbound-full.test.ts.snap @@ -84,6 +84,95 @@ exports[`InboundSchema (full) fixtures > parses hysteria-tls byte-stably 1`] = ` } `; +exports[`InboundSchema (full) fixtures > parses masque-tls byte-stably 1`] = ` +{ + "down": 0, + "enable": true, + "expiryTime": 0, + "id": 31, + "listen": "", + "port": 8443, + "protocol": "masque", + "remark": "ivy-masque", + "settings": { + "address": [ + "10.14.0.1/24", + "fd14::1/64", + ], + "clients": [ + { + "comment": "", + "email": "ivy@example.test", + "enable": true, + "expiryTime": 0, + "limitIp": 0, + "password": "masque-pass-ivy", + "reset": 0, + "subId": "masque-001", + "tgId": 0, + "totalGB": 0, + }, + ], + "mtu": 1400, + }, + "shareAddr": "", + "shareAddrStrategy": "node", + "sniffing": { + "destOverride": [ + "http", + "tls", + "quic", + "fakedns", + ], + "domainsExcluded": [], + "enabled": true, + "ipsExcluded": [], + "metadataOnly": false, + "routeOnly": false, + }, + "streamSettings": { + "masqueSettings": { + "path": "/.well-known/masque/ip/*/*/", + }, + "network": "masque", + "security": "tls", + "tlsSettings": { + "alpn": [ + "h3", + "h2", + ], + "certificates": [ + { + "buildChain": false, + "certificateFile": "/etc/ssl/certs/masque.crt", + "keyFile": "/etc/ssl/private/masque.key", + "ocspStapling": 0, + "oneTimeLoading": false, + "usage": "encipherment", + }, + ], + "cipherSuites": "", + "disableSystemRoot": false, + "echServerKeys": "", + "enableSessionResumption": false, + "maxVersion": "1.3", + "minVersion": "1.2", + "rejectUnknownSni": false, + "serverName": "masque.example.test", + "settings": { + "echConfigList": "", + "fingerprint": "chrome", + "pinnedPeerCertSha256": [], + "verifyPeerCertByName": "", + }, + }, + }, + "tag": "inbound-masque", + "total": 0, + "up": 0, +} +`; + exports[`InboundSchema (full) fixtures > parses shadowsocks-tcp-2022 byte-stably 1`] = ` { "down": 0, diff --git a/frontend/src/test/__snapshots__/inbound-link.test.ts.snap b/frontend/src/test/__snapshots__/inbound-link.test.ts.snap index b844d9f4c..75d6535f0 100644 --- a/frontend/src/test/__snapshots__/inbound-link.test.ts.snap +++ b/frontend/src/test/__snapshots__/inbound-link.test.ts.snap @@ -4,6 +4,8 @@ exports[`genHysteriaLink > hysteria-tls: byte-stable 1`] = `"hysteria2://hyst-v1 exports[`genInboundLinks orchestrator > hysteria-tls: byte-stable 1`] = `"hysteria2://hyst-v1-auth-XYZ@override.test:36715?security=tls&fp=chrome&alpn=h3&sni=hysteria.example.test#parity-test"`; +exports[`genInboundLinks orchestrator > masque-tls: byte-stable 1`] = `""`; + exports[`genInboundLinks orchestrator > shadowsocks-tcp-2022: byte-stable 1`] = `"ss://2022-blake3-aes-256-gcm:ZmFrZS1zZXJ2ZXItcGFzc3dvcmQtMDAwMQ%3D%3D:dGVzdC1jbGllbnQtcGFzc3dvcmQtMQ%3D%3D@override.test:8388?type=tcp#parity-test"`; exports[`genInboundLinks orchestrator > trojan-ws-tls: byte-stable 1`] = `"trojan://trojan-test-pw-XYZ@override.test:443?type=ws&path=%2Ftrojan&host=trojan.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=trojan.example.test#parity-test"`; diff --git a/frontend/src/test/client-form-modal.test.tsx b/frontend/src/test/client-form-modal.test.tsx index 460d0d5e1..9cd9c47cc 100644 --- a/frontend/src/test/client-form-modal.test.tsx +++ b/frontend/src/test/client-form-modal.test.tsx @@ -43,7 +43,7 @@ function tooltipIconForLabel(label: string): HTMLElement { } describe('ClientFormModal credential tooltips', () => { - it('explains that the Password field is only consumed by Trojan/Shadowsocks', async () => { + it('explains which protocols consume the Password field', async () => { renderModal(); openCredentialsTab(); @@ -52,7 +52,7 @@ describe('ClientFormModal credential tooltips', () => { await waitFor(() => { expect(document.body.textContent).toContain( - 'Used by Trojan, Shadowsocks, and TUIC clients; ignored for VLESS, VMess, Hysteria, and WireGuard.', + 'Used by Trojan, Shadowsocks, TUIC, and MASQUE clients; ignored for VLESS, VMess, Hysteria, and WireGuard.', ); }); }); diff --git a/frontend/src/test/golden/fixtures/inbound-full/masque-tls.json b/frontend/src/test/golden/fixtures/inbound-full/masque-tls.json new file mode 100644 index 000000000..2e8a5393c --- /dev/null +++ b/frontend/src/test/golden/fixtures/inbound-full/masque-tls.json @@ -0,0 +1,70 @@ +{ + "id": 31, + "up": 0, + "down": 0, + "total": 0, + "remark": "ivy-masque", + "enable": true, + "expiryTime": 0, + "listen": "", + "port": 8443, + "tag": "inbound-masque", + "sniffing": { + "enabled": true, + "destOverride": ["http", "tls", "quic", "fakedns"], + "metadataOnly": false, + "routeOnly": false, + "ipsExcluded": [], + "domainsExcluded": [] + }, + "protocol": "masque", + "settings": { + "clients": [ + { + "password": "masque-pass-ivy", + "email": "ivy@example.test", + "limitIp": 0, + "totalGB": 0, + "expiryTime": 0, + "enable": true, + "tgId": 0, + "subId": "masque-001", + "comment": "", + "reset": 0 + } + ], + "address": ["10.14.0.1/24", "fd14::1/64"], + "mtu": 1400 + }, + "streamSettings": { + "network": "masque", + "masqueSettings": { + "path": "/.well-known/masque/ip/*/*/" + }, + "security": "tls", + "tlsSettings": { + "serverName": "masque.example.test", + "minVersion": "1.2", + "maxVersion": "1.3", + "cipherSuites": "", + "rejectUnknownSni": false, + "disableSystemRoot": false, + "enableSessionResumption": false, + "certificates": [ + { + "certificateFile": "/etc/ssl/certs/masque.crt", + "keyFile": "/etc/ssl/private/masque.key", + "oneTimeLoading": false, + "usage": "encipherment", + "buildChain": false + } + ], + "alpn": ["h3", "h2"], + "echServerKeys": "", + "settings": { + "fingerprint": "chrome", + "echConfigList": "" + } + } + } +} diff --git a/frontend/src/test/inbound-form-modal.test.tsx b/frontend/src/test/inbound-form-modal.test.tsx index e5a6cf593..0b5f881c2 100644 --- a/frontend/src/test/inbound-form-modal.test.tsx +++ b/frontend/src/test/inbound-form-modal.test.tsx @@ -5,6 +5,7 @@ import InboundFormModal from '@/pages/inbounds/form/InboundFormModal'; import { DBInbound } from '@/models/dbinbound'; import { ThemeProvider } from '@/hooks/useTheme'; import { HttpUtil } from '@/utils'; +import { Protocols } from '@/schemas/primitives'; import { renderWithProviders, fieldLabels, @@ -39,6 +40,22 @@ function renderModal() { ); } +// The protocol dropdown is virtualized, so jsdom renders only its first options; step +// the active option by keyboard from the current protocol in the options' own order. +function chooseVirtualizedProtocol(optionText: string) { + const input = document.getElementById('protocol') as HTMLElement; + const select = input.closest('.ant-select') as HTMLElement; + const current = (select.textContent ?? '').trim(); + const order = Object.values(Protocols) as string[]; + const steps = order.indexOf(optionText) - order.indexOf(current); + if (order.indexOf(optionText) < 0 || order.indexOf(current) < 0 || steps < 0) { + throw new Error(`cannot step from '${current}' to '${optionText}'`); + } + fireEvent.mouseDown(select.querySelector('.ant-select-selector') ?? select); + for (let i = 0; i < steps; i++) fireEvent.keyDown(input, { key: 'ArrowDown', keyCode: 40 }); + fireEvent.keyDown(input, { key: 'Enter', keyCode: 13 }); +} + function primaryButton(): HTMLElement { const button = document.querySelector('.ant-modal-footer .ant-btn-primary'); if (!button) throw new Error('Primary modal button not found'); @@ -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 () => { diff --git a/frontend/src/test/inbound-tag.test.ts b/frontend/src/test/inbound-tag.test.ts index 4c2a77338..d2c1826c9 100644 --- a/frontend/src/test/inbound-tag.test.ts +++ b/frontend/src/test/inbound-tag.test.ts @@ -31,6 +31,27 @@ describe('composeInboundTag transport suffix parity', () => { 'in-443-udp', ], ['wireguard forced udp', base({ protocol: 'wireguard' }), 'in-443-udp'], + [ + 'masque without alpn is udp', + base({ protocol: 'masque', streamSettings: { network: 'masque', tlsSettings: {} } }), + 'in-443-udp', + ], + [ + 'masque h2 is tcp', + base({ + protocol: 'masque', + streamSettings: { network: 'masque', tlsSettings: { alpn: ['h2'] } }, + }), + 'in-443-tcp', + ], + [ + 'masque h3+h2 is both', + base({ + protocol: 'masque', + streamSettings: { network: 'masque', tlsSettings: { alpn: ['h3', 'h2'] } }, + }), + 'in-443-tcpudp', + ], [ 'tuic forced udp', base({ protocol: 'tuic', streamSettings: { network: 'tcp' } }), diff --git a/frontend/src/test/outbound-form-modal.test.tsx b/frontend/src/test/outbound-form-modal.test.tsx index 565aa3d1a..60ce26e5d 100644 --- a/frontend/src/test/outbound-form-modal.test.tsx +++ b/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) => ( + + + {}} + onConfirm={onConfirm} + /> + + + ); + const { rerender } = render(tree(false)); + rerender(tree(true)); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + toggleSwitch('WARP'); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + fireEvent.change(screen.getByLabelText('Private key'), { target: { value: 'PRIVATE-PEM' } }); + fireEvent.change(screen.getByLabelText('Endpoint public key'), { + target: { value: 'PUBLIC-PEM' }, + }); + const ok = document.querySelector('.ant-modal-footer .ant-btn-primary') as HTMLElement; + await act(async () => { + fireEvent.click(ok); + }); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + expect(onConfirm).toHaveBeenCalledTimes(1); + const payload = onConfirm.mock.calls[0][0] as { + protocol: string; + settings: Record; + streamSettings: { + network: string; + security: string; + masqueSettings: Record; + }; + }; + expect(payload.protocol).toBe('masque'); + expect(payload.settings).toMatchObject({ address: '162.159.198.1', port: 443 }); + expect(payload.streamSettings).toMatchObject({ network: 'masque', security: 'tls' }); + expect(payload.streamSettings.masqueSettings.warp).toMatchObject({ + privateKey: 'PRIVATE-PEM', + publicKey: 'PUBLIC-PEM', + }); + expect(payload.streamSettings.masqueSettings.user).toBeUndefined(); + expect(payload.streamSettings.masqueSettings.pass).toBeUndefined(); + }); +}); diff --git a/internal/database/model/model.go b/internal/database/model/model.go index cabbbb2de..831f14803 100644 --- a/internal/database/model/model.go +++ b/internal/database/model/model.go @@ -34,6 +34,7 @@ const ( MTProto Protocol = "mtproto" AmneziaWG Protocol = "amneziawg" TUIC Protocol = "tuic" + MASQUE Protocol = "masque" ) // User represents a user account in the 3x-ui panel. @@ -371,6 +372,10 @@ func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig { if healed, ok := HealHysteriaVersion(settings); ok { settings = healed } + case MASQUE: + if converted, ok := MasqueClientsToCore(settings); ok { + settings = converted + } } streamSettings := i.StreamSettings if stripped, ok := StripInboundXhttpClientFields(streamSettings); ok { @@ -426,6 +431,46 @@ func StripVmessClientSecurity(settings string) (string, bool) { return string(out), true } +// MasqueClientsToCore renames each client's panel "password" to the "pass" key the +// MASQUE server reads; xray-core refuses the whole inbound when one user's pass is empty. +func MasqueClientsToCore(settings string) (string, bool) { + if settings == "" { + return settings, false + } + var parsed map[string]any + if err := json.Unmarshal([]byte(settings), &parsed); err != nil { + return settings, false + } + clients, ok := parsed["clients"].([]any) + if !ok { + return settings, false + } + changed := false + for i := range clients { + cm, ok := clients[i].(map[string]any) + if !ok { + continue + } + password, has := cm["password"] + if !has { + continue + } + if _, set := cm["pass"]; !set { + cm["pass"] = password + } + delete(cm, "password") + changed = true + } + if !changed { + return settings, false + } + out, err := json.MarshalIndent(parsed, "", " ") + if err != nil { + return settings, false + } + return string(out), true +} + // WireguardPeerFromClient builds the xray wireguard inbound peer object for one // WireGuard client. It is the single definition of the peer shape, shared by the // full-config path (XrayService.GetXrayConfig) and the live AddInbound path diff --git a/internal/sub/json_service.go b/internal/sub/json_service.go index fd615b2f8..991f2ea60 100644 --- a/internal/sub/json_service.go +++ b/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, diff --git a/internal/sub/masque_test.go b/internal/sub/masque_test.go new file mode 100644 index 000000000..4486bdf89 --- /dev/null +++ b/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":"ivy@example.test","password":"pw-ivy"}],"address":["10.14.0.1/24"]}`, + StreamSettings: `{"network":"masque","security":"tls", + "masqueSettings":{"path":"/.well-known/masque/ip/*/*/"}, + "tlsSettings":{"serverName":"masque.example.test","alpn":["h3"], + "certificates":[{"certificateFile":"/etc/ssl/m.crt","keyFile":"/etc/ssl/m.key"}], + "settings":{"fingerprint":"chrome"}}}`, + } + client := model.Client{Email: "ivy@example.test", Password: "pw-ivy"} + + configs := NewSubJsonService("", "", "", "", nil).getConfig(&SubService{address: "sub.example.com"}, inbound, client, "sub.example.com") + if len(configs) != 1 { + t.Fatalf("got %d configs, want one", len(configs)) + } + var config struct { + Outbounds []struct { + Protocol string `json:"protocol"` + Settings map[string]any `json:"settings"` + StreamSettings map[string]any `json:"streamSettings"` + } `json:"outbounds"` + } + if err := json.Unmarshal(configs[0], &config); err != nil { + t.Fatalf("decode config: %v", err) + } + proxy := config.Outbounds[0] + if proxy.Protocol != "masque" { + t.Fatalf("first outbound protocol = %q, want masque", proxy.Protocol) + } + if proxy.Settings["address"] != "203.0.113.9" || proxy.Settings["port"] != float64(8443) { + t.Fatalf("masque settings = %v, want the inbound address and port", proxy.Settings) + } + wantMasque := map[string]any{"path": "/.well-known/masque/ip/*/*/", "user": "ivy@example.test", "pass": "pw-ivy"} + if got := proxy.StreamSettings["masqueSettings"]; !reflect.DeepEqual(got, wantMasque) { + t.Fatalf("masqueSettings = %v, want %v", got, wantMasque) + } + tls, _ := proxy.StreamSettings["tlsSettings"].(map[string]any) + if proxy.StreamSettings["network"] != "masque" || proxy.StreamSettings["security"] != "tls" || + tls["serverName"] != "masque.example.test" || tls["certificates"] != nil { + t.Fatalf("stream = %v, want masque over client-side TLS to masque.example.test", proxy.StreamSettings) + } +} + +// The subscription SQL allowlist decides which inbounds a subId can see at all; left +// off it, a MASQUE client's JSON subscription would come back empty. +func TestGetInboundsBySubIdIncludesMasque(t *testing.T) { + initSubDB(t) + db := database.GetDB() + + in := &model.Inbound{Port: 8443, Protocol: model.MASQUE, Enable: true, Tag: "masque-sub", Settings: `{"clients":[],"address":["10.14.0.1/24"]}`} + if err := db.Create(in).Error; err != nil { + t.Fatalf("create inbound: %v", err) + } + rec := &model.ClientRecord{Email: "u@masque", SubID: "submasque", Enable: true} + if err := db.Create(rec).Error; err != nil { + t.Fatalf("create client: %v", err) + } + if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: in.Id}).Error; err != nil { + t.Fatalf("create link: %v", err) + } + + inbounds, err := (&SubService{}).getInboundsBySubId("submasque") + if err != nil { + t.Fatalf("getInboundsBySubId: %v", err) + } + if len(inbounds) != 1 || inbounds[0].Id != in.Id { + t.Fatalf("masque inbound not returned for subId: %+v", inbounds) + } +} diff --git a/internal/sub/service.go b/internal/sub/service.go index 2867ff2be..feca3d04d 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -685,7 +685,7 @@ func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id JOIN clients ON clients.id = client_inbounds.client_id WHERE - inbounds.protocol in ('vmess','vless','trojan','shadowsocks','hysteria','wireguard','amneziawg','mtproto','tuic') + inbounds.protocol in ('vmess','vless','trojan','shadowsocks','hysteria','wireguard','amneziawg','mtproto','tuic','masque') AND clients.sub_id = ? AND inbounds.enable = ? )`, subId, true).Order("sub_sort_index ASC").Order("id ASC").Find(&inbounds).Error if err != nil { diff --git a/internal/web/job/check_client_ip_job.go b/internal/web/job/check_client_ip_job.go index 5e802550e..1e1a3720d 100644 --- a/internal/web/job/check_client_ip_job.go +++ b/internal/web/job/check_client_ip_job.go @@ -694,7 +694,7 @@ func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, c // wireguard stays out: keepAlive marshals as a number, AddUser wants a string. protocol := string(inbound.Protocol) switch protocol { - case "vmess", "vless", "trojan", "shadowsocks", "hysteria": + case "vmess", "vless", "trojan", "shadowsocks", "hysteria", "masque": // supported protocols, continue default: logger.Warningf("[LIMIT_IP] Temporary disconnect is not supported for protocol %s on inbound %s", protocol, inbound.Tag) diff --git a/internal/web/service/client_crud.go b/internal/web/service/client_crud.go index e967c0e01..e8fe14629 100644 --- a/internal/web/service/client_crud.go +++ b/internal/web/service/client_crud.go @@ -411,7 +411,7 @@ func (s *ClientService) fillProtocolDefaults(c *model.Client, ib *model.Inbound) if c.ID == "" { c.ID = uuid.NewString() } - case model.Trojan: + case model.Trojan, model.MASQUE: if c.Password == "" { c.Password = strings.ReplaceAll(uuid.NewString(), "-", "") } diff --git a/internal/web/service/client_inbound_apply.go b/internal/web/service/client_inbound_apply.go index b4386ca0c..3003759db 100644 --- a/internal/web/service/client_inbound_apply.go +++ b/internal/web/service/client_inbound_apply.go @@ -443,7 +443,7 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model return false, common.NewError("client email is required") } switch oldInbound.Protocol { - case "trojan": + case "trojan", "masque": if client.Password == "" { return false, common.NewError("empty client ID") } @@ -688,7 +688,7 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo newClientId := "" switch oldInbound.Protocol { - case "trojan": + case "trojan", "masque": newClientId = clients[0].Password case "shadowsocks": newClientId = clients[0].Email diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index ab4408c44..cce52ff4a 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -656,6 +656,7 @@ func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) { model.Hysteria: true, model.WireGuard: true, model.Tunnel: true, + model.MASQUE: true, } if !protocolsWithStream[inbound.Protocol] { @@ -1241,7 +1242,7 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo // Secure client ID for _, client := range clients { switch inbound.Protocol { - case "trojan": + case "trojan", "masque": if client.Password == "" { return inbound, false, common.NewError("empty client ID") } @@ -1767,6 +1768,10 @@ func (s *InboundService) validateUpdatedInboundClients(inbound *model.Inbound) e if client.Auth == "" { return common.NewError("empty client ID") } + case model.MASQUE: + if client.Password == "" { + return common.NewError("empty client ID") + } case model.TUIC: if client.ID == "" { return common.NewError("empty client ID") diff --git a/internal/web/service/inbound_clients.go b/internal/web/service/inbound_clients.go index 5df50a142..0391affdb 100644 --- a/internal/web/service/inbound_clients.go +++ b/internal/web/service/inbound_clients.go @@ -230,7 +230,7 @@ func (s *InboundService) buildTargetClientFromSource(source model.Client, target inboundCanEnableTlsFlow(string(targetProtocol), targetInbound.StreamSettings, targetInbound.Settings) { target.Flow = flow } - case model.Trojan, model.Shadowsocks: + case model.Trojan, model.Shadowsocks, model.MASQUE: target.Password = s.generateRandomCredential(targetProtocol) case model.Hysteria: target.Auth = s.generateRandomCredential(targetProtocol) diff --git a/internal/web/service/inbound_masque_test.go b/internal/web/service/inbound_masque_test.go new file mode 100644 index 000000000..ff1688bc3 --- /dev/null +++ b/internal/web/service/inbound_masque_test.go @@ -0,0 +1,101 @@ +package service + +import ( + "encoding/json" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +const masqueTestStream = `{"network":"masque","security":"tls","masqueSettings":{"path":"/.well-known/masque/ip/*/*/"}, + "tlsSettings":{"alpn":["h3"],"certificates":[{"certificateFile":"/etc/ssl/certs/m.crt","keyFile":"/etc/ssl/private/m.key"}]}}` + +// GetXrayConfig rebuilds every inbound's users from the clients table; a MASQUE user +// emitted without its password makes xray-core refuse the whole config at startup. +func TestGetXrayConfig_EmitsMasqueUserPasswords(t *testing.T) { + setupConflictDB(t) + in := &model.Inbound{ + Tag: "in-8443-masque", Enable: true, Listen: "127.0.0.1", Port: 8443, Protocol: model.MASQUE, + Settings: `{"clients":[{"email":"ivy@example.test","password":"masque-pass-ivy","enable":true}],"address":["10.14.0.1/24"]}`, + StreamSettings: masqueTestStream, + } + if _, _, err := (&InboundService{}).AddInbound(in); err != nil { + t.Fatalf("AddInbound: %v", err) + } + + cfg, err := (&XrayService{}).GetXrayConfig() + if err != nil { + t.Fatalf("GetXrayConfig: %v", err) + } + for i := range cfg.InboundConfigs { + if cfg.InboundConfigs[i].Tag != "in-8443-masque" { + continue + } + var settings struct { + Clients []map[string]any `json:"clients"` + } + if err := json.Unmarshal(cfg.InboundConfigs[i].Settings, &settings); err != nil { + t.Fatalf("decode emitted settings: %v", err) + } + if len(settings.Clients) != 1 || settings.Clients[0]["pass"] != "masque-pass-ivy" { + t.Fatalf("emitted clients = %v, want one with pass masque-pass-ivy", settings.Clients) + } + raw, err := json.Marshal(cfg.InboundConfigs[i]) + if err != nil { + t.Fatalf("marshal emitted inbound: %v", err) + } + var emitted map[string]any + if err := json.Unmarshal(raw, &emitted); err != nil { + t.Fatalf("decode emitted inbound: %v", err) + } + if stream, _ := emitted["streamSettings"].(map[string]any); stream["network"] != "masque" { + t.Fatalf("emitted streamSettings = %v, want the stored masque transport", emitted["streamSettings"]) + } + assertXrayAccepts(t, "the emitted MASQUE inbound", buildGoldenInbound(t, emitted)) + return + } + t.Fatal("inbound in-8443-masque not found in the generated config") +} + +// A client added to a MASQUE inbound without a password could never authenticate, +// and xray-core refuses the whole inbound over one empty pass. +func TestFillProtocolDefaults_MintsMasquePassword(t *testing.T) { + client := model.Client{Email: "ivy@example.test"} + if err := (&ClientService{}).fillProtocolDefaults(&client, &model.Inbound{Protocol: model.MASQUE}); err != nil { + t.Fatalf("fillProtocolDefaults: %v", err) + } + if len(client.Password) != 32 { + t.Fatalf("Password = %q, want a minted 32-character password", client.Password) + } + kept := model.Client{Email: "ivy@example.test", Password: "chosen"} + if err := (&ClientService{}).fillProtocolDefaults(&kept, &model.Inbound{Protocol: model.MASQUE}); err != nil { + t.Fatalf("fillProtocolDefaults: %v", err) + } + if kept.Password != "chosen" { + t.Fatalf("Password = %q, want the chosen password kept", kept.Password) + } +} + +// Editing a MASQUE client must key on its password like trojan; falling back to the +// empty UUID refused every MASQUE client edit with "empty client ID". +func TestUpdateInboundClient_MasquePasswordChange(t *testing.T) { + setupBulkDB(t) + svc := &ClientService{} + source := []model.Client{{Email: "ivy@x", Password: "pw-old", SubID: "sub-ivy", Enable: true}} + ib := mkInbound(t, 22101, model.MASQUE, clientsSettings(t, source)) + if err := svc.SyncInbound(nil, ib.Id, source); err != nil { + t.Fatalf("seed linkage: %v", err) + } + + updated := []model.Client{{Email: "ivy@x", Password: "pw-new", SubID: "sub-ivy", Enable: true}} + if _, err := svc.UpdateInboundClient(&InboundService{}, &model.Inbound{ + Id: ib.Id, + Settings: clientsSettings(t, updated), + }, "ivy@x"); err != nil { + t.Fatalf("UpdateInboundClient: %v", err) + } + + if rec := lookupClientRecord(t, "ivy@x"); rec.Password != "pw-new" { + t.Fatalf("stored password = %q, want pw-new", rec.Password) + } +} diff --git a/internal/web/service/inbound_protocol.go b/internal/web/service/inbound_protocol.go index b13f7a383..c03ecfaa3 100644 --- a/internal/web/service/inbound_protocol.go +++ b/internal/web/service/inbound_protocol.go @@ -70,6 +70,7 @@ var nodeEligibleProtocols = map[model.Protocol]bool{ model.MTProto: true, model.AmneziaWG: true, model.TUIC: true, + model.MASQUE: true, } // nodeProtocolFirstRelease is the panel release that introduced each protocol @@ -78,6 +79,7 @@ var nodeProtocolFirstRelease = map[model.Protocol]string{ model.MTProto: "v3.5.0", model.AmneziaWG: "v3.7.0", model.TUIC: "v3.8.0", + model.MASQUE: "v3.9.1", } // checkNodeCanHostProtocol refuses assigning protocol to nodeID unless the diff --git a/internal/web/service/inbound_protocol_masque_test.go b/internal/web/service/inbound_protocol_masque_test.go new file mode 100644 index 000000000..c6df5b5ed --- /dev/null +++ b/internal/web/service/inbound_protocol_masque_test.go @@ -0,0 +1,41 @@ +package service + +import ( + "strings" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +// MASQUE is native Xray, so a node runs it, but a node panel older than the release +// that knows it would hand the clients' "password" to a core that reads "pass". +func TestCheckNodeCanHostProtocol_Masque(t *testing.T) { + setupConflictDB(t) + tests := []struct { + version string + wantErr string + }{ + {"v3.9.0", "need v3.9.1 or newer"}, + {"v3.9.1", ""}, + {"dev+1d1128cf", ""}, + } + for i, tc := range tests { + t.Run(tc.version, func(t *testing.T) { + node := &model.Node{Name: "masque-node-" + tc.version, Address: "127.0.0.1", Port: 2096 + i, PanelVersion: tc.version} + if err := database.GetDB().Create(node).Error; err != nil { + t.Fatalf("create node: %v", err) + } + err := checkNodeCanHostProtocol(database.GetDB(), node.Id, model.MASQUE) + if tc.wantErr == "" { + if err != nil { + t.Fatalf("node on %s refused MASQUE: %v", tc.version, err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("node on %s: err = %v, want one containing %q", tc.version, err, tc.wantErr) + } + }) + } +} diff --git a/internal/web/service/outbound/outbound.go b/internal/web/service/outbound/outbound.go index 1e9ce2ad2..ae5e77607 100644 --- a/internal/web/service/outbound/outbound.go +++ b/internal/web/service/outbound/outbound.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net" + "slices" "strconv" "strings" "sync" @@ -225,6 +226,9 @@ func outboundTransportIsUDP(ob map[string]any) bool { if protocol, _ := ob["protocol"].(string); equalsAnyFold(protocol, "hysteria", "wireguard", "amneziawg") { return true } + if protocol, _ := ob["protocol"].(string); equalsAnyFold(protocol, "masque") { + return masqueDialsHTTP3(ob) + } if stream, ok := ob["streamSettings"].(map[string]any); ok { // The core resolves "kcp" and "mkcp" to the same mKCP transport. if n, _ := stream["network"].(string); equalsAnyFold(n, "hysteria", "kcp", "mkcp", "quic") { @@ -234,6 +238,15 @@ func outboundTransportIsUDP(ob map[string]any) bool { return false } +// masqueDialsHTTP3 mirrors the core's MASQUE client, which falls back to HTTP/2 over +// TCP only when the TLS ALPN offers h2 and not h3. +func masqueDialsHTTP3(ob map[string]any) bool { + stream, _ := ob["streamSettings"].(map[string]any) + tlsSettings, _ := stream["tlsSettings"].(map[string]any) + alpn, _ := tlsSettings["alpn"].([]any) + return !slices.Contains(alpn, any("h2")) || slices.Contains(alpn, any("h3")) +} + // equalsAnyFold mirrors the core, which lowercases a protocol id and a // transport name before it resolves either of them. func equalsAnyFold(value string, want ...string) bool { @@ -281,7 +294,7 @@ func extractOutboundEndpoints(ob map[string]any) []string { if len(out) == 0 { addServer(settings["address"], settings["port"]) } - case "hysteria": + case "hysteria", "masque": addServer(settings["address"], settings["port"]) case "trojan", "shadowsocks", "http", "socks": if servers, ok := settings["servers"].([]any); ok { diff --git a/internal/web/service/outbound/outbound_endpoints_test.go b/internal/web/service/outbound/outbound_endpoints_test.go index e76a13887..225d03487 100644 --- a/internal/web/service/outbound/outbound_endpoints_test.go +++ b/internal/web/service/outbound/outbound_endpoints_test.go @@ -52,3 +52,15 @@ func TestExtractOutboundEndpointsVLESS(t *testing.T) { }) } } + +// A MASQUE outbound names its server flat in settings, like hysteria; without it the +// outbound test has no endpoint to resolve and reports the outbound unreachable. +func TestExtractOutboundEndpointsMasque(t *testing.T) { + got := extractOutboundEndpoints(map[string]any{ + "protocol": "masque", + "settings": map[string]any{"address": "masque.example.com", "port": float64(8443)}, + }) + if want := []string{"masque.example.com:8443"}; !reflect.DeepEqual(got, want) { + t.Fatalf("extractOutboundEndpoints() = %v, want %v", got, want) + } +} diff --git a/internal/web/service/outbound/probe_protocol_case_test.go b/internal/web/service/outbound/probe_protocol_case_test.go index 8109df16f..4c187bfa8 100644 --- a/internal/web/service/outbound/probe_protocol_case_test.go +++ b/internal/web/service/outbound/probe_protocol_case_test.go @@ -51,6 +51,9 @@ func TestOutboundTransportIsUDPMatchesTheCore(t *testing.T) { {"capitalised wireguard", map[string]any{"protocol": "WireGuard"}, true}, {"upper hysteria", map[string]any{"protocol": "HYSTERIA"}, true}, {"amneziawg", map[string]any{"protocol": "amneziawg"}, true}, + // The core's MASQUE client dials HTTP/3 over QUIC unless the ALPN offers h2 without h3. + {"masque over http/3", map[string]any{"protocol": "masque", "streamSettings": map[string]any{"network": "masque"}}, true}, + {"masque pinned to http/2", map[string]any{"protocol": "masque", "streamSettings": map[string]any{"network": "masque", "tlsSettings": map[string]any{"alpn": []any{"h2"}}}}, false}, {"kcp transport", map[string]any{"streamSettings": map[string]any{"network": "kcp"}}, true}, {"kcp transport capitalised", map[string]any{"streamSettings": map[string]any{"network": "KCP"}}, true}, {"mkcp alias", map[string]any{"streamSettings": map[string]any{"network": "mkcp"}}, true}, diff --git a/internal/web/service/port_conflict.go b/internal/web/service/port_conflict.go index 03ab9cba0..b26134e20 100644 --- a/internal/web/service/port_conflict.go +++ b/internal/web/service/port_conflict.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "net" + "slices" "strings" "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" @@ -30,6 +31,8 @@ func inboundTransports(protocol model.Protocol, streamSettings, settings string) return transportUDP case model.MTProto: return transportTCP + case model.MASQUE: + return masqueTransports(streamSettings) } var bits transportBits @@ -93,6 +96,26 @@ func inboundTransports(protocol model.Protocol, streamSettings, settings string) return bits } +// masqueTransports mirrors xray-core's MASQUE listener: HTTP/2 on TCP when the TLS +// ALPN offers h2, HTTP/3 on UDP when it offers h3 or does not offer h2. +func masqueTransports(streamSettings string) transportBits { + var stream struct { + TLSSettings struct { + ALPN []string `json:"alpn"` + } `json:"tlsSettings"` + } + _ = json.Unmarshal([]byte(streamSettings), &stream) + h2 := slices.Contains(stream.TLSSettings.ALPN, "h2") + var bits transportBits + if h2 { + bits |= transportTCP + } + if !h2 || slices.Contains(stream.TLSSettings.ALPN, "h3") { + bits |= transportUDP + } + return bits +} + // bindAddr is a listen address plus sockopt.v6only. xray listens on "tcp"/"udp", // so Go opens every wildcard, 0.0.0.0 included, dual-stack unless v6only is set. type bindAddr struct { diff --git a/internal/web/service/port_conflict_test.go b/internal/web/service/port_conflict_test.go index c3a1fe356..d65cb1791 100644 --- a/internal/web/service/port_conflict_test.go +++ b/internal/web/service/port_conflict_test.go @@ -75,6 +75,13 @@ func TestInboundTransports(t *testing.T) { {"hysteria forced udp", model.Hysteria, `{"network":"tcp"}`, ``, transportUDP}, {"wireguard forced udp", model.WireGuard, ``, ``, transportUDP}, + // MASQUE serves HTTP/3 on UDP unless the TLS ALPN offers only h2, and adds an + // HTTP/2 listener on TCP whenever h2 is offered. + {"masque without alpn is udp", model.MASQUE, `{"network":"masque","tlsSettings":{}}`, ``, transportUDP}, + {"masque h3 is udp", model.MASQUE, `{"network":"masque","tlsSettings":{"alpn":["h3"]}}`, ``, transportUDP}, + {"masque h2 is tcp", model.MASQUE, `{"network":"masque","tlsSettings":{"alpn":["h2"]}}`, ``, transportTCP}, + {"masque h3+h2 is both", model.MASQUE, `{"network":"masque","tlsSettings":{"alpn":["h3","h2"]}}`, ``, transportTCP | transportUDP}, + {"shadowsocks tcp,udp", model.Shadowsocks, ``, `{"network":"tcp,udp"}`, transportTCP | transportUDP}, {"shadowsocks udp only", model.Shadowsocks, ``, `{"network":"udp"}`, transportUDP}, {"shadowsocks tcp only", model.Shadowsocks, ``, `{"network":"tcp"}`, transportTCP}, diff --git a/internal/web/service/xray.go b/internal/web/service/xray.go index e680ec380..edf986cf7 100644 --- a/internal/web/service/xray.go +++ b/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 diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index 920f8f012..f1e5aa9f2 100644 --- a/internal/web/translation/ar-EG.json +++ b/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": "الوسم", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index 95b723f4f..307c591ca 100644 --- a/internal/web/translation/en-US.json +++ b/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", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index e2cb1a73f..118033782 100644 --- a/internal/web/translation/es-ES.json +++ b/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", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index 802815ea2..4004bd407 100644 --- a/internal/web/translation/fa-IR.json +++ b/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": "تگ", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index 81affa685..2daeea7d9 100644 --- a/internal/web/translation/id-ID.json +++ b/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", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index 6b6354fdc..36df3b947 100644 --- a/internal/web/translation/ja-JP.json +++ b/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": "タグ", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index 4c5dee2b3..64143efe7 100644 --- a/internal/web/translation/pt-BR.json +++ b/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", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index ba859b0c5..855ccfc5b 100644 --- a/internal/web/translation/ru-RU.json +++ b/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": "Тег", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index f80bca6e3..6ecac8b1d 100644 --- a/internal/web/translation/tr-TR.json +++ b/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", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index b1547258d..9584b2dc2 100644 --- a/internal/web/translation/uk-UA.json +++ b/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": "Тег", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 71ad6af03..d263a9237 100644 --- a/internal/web/translation/vi-VN.json +++ b/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", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 6a7e54512..e7ab80592 100644 --- a/internal/web/translation/zh-CN.json +++ b/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": "标签", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 1100cb596..7bde6e021 100644 --- a/internal/web/translation/zh-TW.json +++ b/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": "標籤", diff --git a/internal/xray/api.go b/internal/xray/api.go index b49b0588b..fee95d7e4 100644 --- a/internal/xray/api.go +++ b/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 diff --git a/internal/xray/api_masque_test.go b/internal/xray/api_masque_test.go new file mode 100644 index 000000000..ffd03908d --- /dev/null +++ b/internal/xray/api_masque_test.go @@ -0,0 +1,43 @@ +package xray + +import ( + "testing" + + "github.com/xtls/xray-core/proxy/masque" +) + +// Panel paths hand AddUser the client's "password"; the hot diff hands it the user +// as GetXrayConfig emitted it, keyed "pass". Without an account the core adds a +// MASQUE user it can never authenticate. +func TestBuildUserAccountMasque(t *testing.T) { + tests := []struct { + name string + user map[string]any + }{ + {"panel client", map[string]any{"email": "ivy@example.test", "password": "pw-ivy"}}, + {"emitted core user", map[string]any{"email": "ivy@example.test", "pass": "pw-ivy"}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + typed, err := buildUserAccount("masque", tc.user) + if err != nil { + t.Fatalf("buildUserAccount: %v", err) + } + if typed == nil { + t.Fatal("buildUserAccount returned no account") + } + instance, err := typed.GetInstance() + if err != nil { + t.Fatalf("GetInstance: %v", err) + } + account, ok := instance.(*masque.Account) + if !ok || account.Password != "pw-ivy" { + t.Fatalf("account = %#v, want a masque.Account with password pw-ivy", instance) + } + }) + } + + if _, err := buildUserAccount("masque", map[string]any{"email": "ivy@example.test"}); err == nil { + t.Fatal("a MASQUE user without a password must be refused, not added unauthenticatable") + } +} diff --git a/internal/xray/hot_diff.go b/internal/xray/hot_diff.go index b2319bb70..453dfdabd 100644 --- a/internal/xray/hot_diff.go +++ b/internal/xray/hot_diff.go @@ -221,7 +221,7 @@ func droppedClients(oldIb, newIb *InboundConfig) []UserOp { return dropped } -var userDiffableProtocols = map[string]struct{}{"vless": {}, "vmess": {}, "trojan": {}, "hysteria": {}} +var userDiffableProtocols = map[string]struct{}{"vless": {}, "vmess": {}, "trojan": {}, "hysteria": {}, "masque": {}} // diffInboundUsers emits per-user AlterInbound ops when two same-tag inbounds // differ only in settings.clients, so the handler (and its listener) survives. diff --git a/internal/xray/hot_diff_test.go b/internal/xray/hot_diff_test.go index 9acaa46c4..1d694ec49 100644 --- a/internal/xray/hot_diff_test.go +++ b/internal/xray/hot_diff_test.go @@ -211,6 +211,29 @@ func TestComputeHotDiff_ClientOnlyChangeUsesUserOps(t *testing.T) { } } +// A MASQUE inbound carries every user's CONNECT-IP tunnel; replacing the handler to +// add one user would drop all of them, so its client list diffs per user too. +func TestComputeHotDiff_MasqueClientAddUsesUserOps(t *testing.T) { + oldCfg := makeHotConfig() + oldCfg.InboundConfigs[1].Protocol = "masque" + oldCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[{"email":"a","pass":"pw-a"}],"address":["10.14.0.1/24"]}`) + newCfg := makeHotConfig() + newCfg.InboundConfigs[1].Protocol = "masque" + newCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[{"email":"a","pass":"pw-a"},{"email":"b","pass":"pw-b"}],"address":["10.14.0.1/24"]}`) + + diff, ok := ComputeHotDiff(oldCfg, newCfg) + if !ok { + t.Fatal("a MASQUE client-only change must be hot-appliable") + } + if len(diff.RemovedInboundTags) != 0 || len(diff.AddedInbounds) != 0 { + t.Fatalf("a MASQUE client add must not replace the handler, got %+v", diff) + } + if len(diff.RemovedUsers) != 0 || len(diff.AddedUsers) != 1 || + diff.AddedUsers[0].Email != "b" || diff.AddedUsers[0].User["pass"] != "pw-b" { + t.Fatalf("expected only user b added with its pass, got added %+v removed %+v", diff.AddedUsers, diff.RemovedUsers) + } +} + func TestComputeHotDiff_ClientChangeFallsBackToReplace(t *testing.T) { cases := []struct { name string