Ver código fonte

feat(warp): register WARP over MASQUE from the WARP modal

xray-core's masque transport reaches Cloudflare WARP with an enrolled
ECDSA P-256 key, the endpoint's public key and the assigned tunnel
addresses, which only a WARP enrollment hands out.

A new regMasque action on /panel/api/xray/warp/:action registers a
separate WARP device and enrolls a secp256r1 key for the masque tunnel
(the API revision and client version the WARP apps enroll MASQUE keys
with). Enrolling replaces a device's WireGuard key, so it never touches
the stored WireGuard registration. It returns the PEM key pair, the
addresses and the endpoint, and the WARP modal turns them into a
warp-masque outbound (HTTP/3, Cloudflare's MASQUE SNI), or refreshes
the one already there.
MHSanaei 11 horas atrás
pai
commit
04cccef311

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

@@ -40,6 +40,11 @@ Pick **masque** as the outbound protocol, then set:
 
 ## WARP over MASQUE
 
+The quickest way is **Xray → Outbounds → WARP → Add WARP over MASQUE outbound**: the panel
+registers a separate WARP device, enrolls a MASQUE key for it and adds a ready `warp-masque`
+outbound (or refreshes the existing one). Your WireGuard WARP registration is not touched.
+To set it up by hand instead:
+
 Turn on **WARP** in the masque transport to reach Cloudflare WARP through its MASQUE
 endpoint instead of WireGuard. It takes a WARP registration enrolled for MASQUE:
 

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

@@ -40,6 +40,11 @@ icon: Waypoints
 
 ## WARP از طریق MASQUE
 
+ساده‌ترین راه **Xray ← Outbounds ← WARP ← افزودن outbound برای WARP روی MASQUE** است: پنل یک
+دستگاه WARP جداگانه ثبت می‌کند، برای آن کلید MASQUE ثبت می‌کند و یک outbound آمادهٔ `warp-masque`
+اضافه می‌کند (یا outbound موجود را تازه می‌کند). ثبت WARP وایرگارد شما دست نمی‌خورد.
+برای راه‌اندازی دستی:
+
 **WARP** را در transport مربوط به masque روشن کنید تا به‌جای WireGuard از طریق endpoint
 مخصوص MASQUE به Cloudflare WARP برسید. این کار به یک ثبت‌نام WARP نیاز دارد که برای MASQUE
 ثبت شده باشد:

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

@@ -40,6 +40,11 @@ icon: Waypoints
 
 ## WARP через MASQUE
 
+Проще всего: **Xray → Аутбаунды → WARP → Добавить аутбаунд WARP через MASQUE** — панель
+регистрирует отдельное устройство WARP, привязывает к нему ключ MASQUE и добавляет готовый
+аутбаунд `warp-masque` (или обновляет существующий). Регистрация WARP для WireGuard не меняется.
+Для ручной настройки:
+
 Включите **WARP** в транспорте masque, чтобы подключаться к Cloudflare WARP через его
 MASQUE-эндпоинт вместо WireGuard. Для этого нужна регистрация WARP, привязанная к MASQUE:
 

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

@@ -38,6 +38,10 @@ HTTPS 流量无异的完整三层隧道。xray-core 原生支持它;3x-ui 将
 
 ## 通过 MASQUE 使用 WARP
 
+最简单的方式是 **Xray → 出站 → WARP → 添加 WARP over MASQUE 出站**:面板会单独注册一个 WARP 设备、为其注册 MASQUE 密钥,
+并添加一个可直接使用的 `warp-masque` 出站(若已存在则更新)。你的 WireGuard WARP 注册不受影响。
+如需手动设置:
+
 在 masque 传输中开启 **WARP**,即可通过 Cloudflare 的 MASQUE 端点而非 WireGuard 连接 WARP。这需要一个已为
 MASQUE 注册的 WARP 账户:
 

+ 1 - 1
docs/public/openapi.json

@@ -13649,7 +13649,7 @@
             "name": "action",
             "in": "path",
             "required": true,
-            "description": "data — return Warp stats. del — delete Warp data. config — return current config. reg — register (sends keys). changeIp — rotate the endpoint. license — set a Warp+ key. interval — set automatic rotation in hours.",
+            "description": "data — return Warp stats. del — delete Warp data. config — return current config. reg — register (sends keys). regMasque — register a separate device enrolled for MASQUE and return its warp key pair, addresses and endpoint. changeIp — rotate the endpoint. license — set a Warp+ key. interval — set automatic rotation in hours.",
             "schema": {
               "type": "string"
             }

+ 1 - 1
frontend/public/openapi.json

@@ -13649,7 +13649,7 @@
             "name": "action",
             "in": "path",
             "required": true,
-            "description": "data — return Warp stats. del — delete Warp data. config — return current config. reg — register (sends keys). changeIp — rotate the endpoint. license — set a Warp+ key. interval — set automatic rotation in hours.",
+            "description": "data — return Warp stats. del — delete Warp data. config — return current config. reg — register (sends keys). regMasque — register a separate device enrolled for MASQUE and return its warp key pair, addresses and endpoint. changeIp — rotate the endpoint. license — set a Warp+ key. interval — set automatic rotation in hours.",
             "schema": {
               "type": "string"
             }

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

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

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

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

+ 100 - 0
frontend/src/test/warp-masque-modal.test.tsx

@@ -0,0 +1,100 @@
+import { describe, expect, it, vi, afterEach } from 'vitest';
+import { act, fireEvent, screen, waitFor } from '@testing-library/react';
+
+import WarpModal from '@/pages/xray/overrides/WarpModal';
+import { HttpUtil, Msg } from '@/utils';
+import { renderWithProviders } from './test-utils';
+
+const registration = {
+  privateKey: '-----BEGIN PRIVATE KEY-----\nAAA\n-----END PRIVATE KEY-----\n',
+  publicKey: '-----BEGIN PUBLIC KEY-----\nBBB\n-----END PUBLIC KEY-----\n',
+  address: ['172.16.0.2', '2606:4700:110:8a36::2'],
+  endpoint: '162.159.198.1',
+};
+
+function mockApi() {
+  vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => {
+    if (url === '/panel/api/xray/warp/regMasque') {
+      return new Msg(true, '', JSON.stringify(registration));
+    }
+    if (url === '/panel/api/setting/all') return new Msg(true, '', {});
+    return new Msg(true, '', '');
+  });
+}
+
+async function clickAddMasque() {
+  const button = await screen.findByRole('button', { name: /Add WARP over MASQUE outbound/ });
+  // The modal loads its WARP data on open; a loading button swallows the click.
+  await waitFor(() => expect(button.className).not.toContain('ant-btn-loading'));
+  await act(async () => {
+    fireEvent.click(button);
+  });
+}
+
+afterEach(() => {
+  vi.restoreAllMocks();
+});
+
+// WARP over MASQUE dials Cloudflare's MASQUE endpoint with the enrolled key, its SNI
+// and HTTP/3; an outbound missing any of them fails the handshake at runtime.
+describe('WarpModal WARP over MASQUE', () => {
+  const expected = {
+    tag: 'warp-masque',
+    protocol: 'masque',
+    settings: { address: '162.159.198.1', port: 443 },
+    streamSettings: {
+      network: 'masque',
+      security: 'tls',
+      tlsSettings: { serverName: 'consumer-masque.cloudflareclient.com', alpn: ['h3'] },
+      masqueSettings: {
+        warp: {
+          privateKey: registration.privateKey,
+          publicKey: registration.publicKey,
+          address: registration.address,
+        },
+      },
+    },
+  };
+
+  it('adds a MASQUE outbound built from a fresh enrollment', async () => {
+    mockApi();
+    const onAddOutbound = vi.fn();
+    renderWithProviders(
+      <WarpModal
+        open
+        templateSettings={{ outbounds: [{ tag: 'direct' }] }}
+        onClose={() => {}}
+        onAddOutbound={onAddOutbound}
+        onResetOutbound={() => {}}
+        onRemoveOutbound={() => {}}
+      />,
+    );
+
+    await clickAddMasque();
+
+    await waitFor(() => expect(onAddOutbound).toHaveBeenCalledWith(expected));
+  });
+
+  it('replaces an existing warp-masque outbound instead of adding a second one', async () => {
+    mockApi();
+    const onAddOutbound = vi.fn();
+    const onResetOutbound = vi.fn();
+    renderWithProviders(
+      <WarpModal
+        open
+        templateSettings={{ outbounds: [{ tag: 'direct' }, { tag: 'warp-masque' }] }}
+        onClose={() => {}}
+        onAddOutbound={onAddOutbound}
+        onResetOutbound={onResetOutbound}
+        onRemoveOutbound={() => {}}
+      />,
+    );
+
+    await clickAddMasque();
+
+    await waitFor(() =>
+      expect(onResetOutbound).toHaveBeenCalledWith({ index: 1, outbound: expected }),
+    );
+    expect(onAddOutbound).not.toHaveBeenCalled();
+  });
+});

+ 2 - 0
internal/web/controller/xray_setting.go

@@ -195,6 +195,8 @@ func (a *XraySettingController) warp(c *gin.Context) {
 		skey := c.PostForm("privateKey")
 		pkey := c.PostForm("publicKey")
 		resp, err = a.WarpService.RegWarp(skey, pkey)
+	case "regMasque":
+		resp, err = a.WarpService.RegWarpMasque()
 	case "changeIp":
 		resp, err = a.WarpService.ChangeWarpIP()
 		if err == nil {

+ 126 - 0
internal/web/service/integration/warp.go

@@ -3,9 +3,16 @@ package integration
 import (
 	"bytes"
 	"context"
+	"crypto/ecdsa"
+	"crypto/elliptic"
+	"crypto/rand"
+	"crypto/x509"
+	"encoding/base64"
 	"encoding/json"
+	"encoding/pem"
 	"fmt"
 	"io"
+	"net"
 	"net/http"
 	"os"
 	"time"
@@ -30,6 +37,12 @@ const (
 // (not a const) so integration tests can point it at a mock server.
 var warpAPIBase = "https://api.cloudflareclient.com/v0a4005"
 
+// warpMasqueAPIBase and warpMasqueClientVer are the API revision and client the WARP
+// apps enroll secp256r1 MASQUE keys with; a var so tests can point it at a mock.
+var warpMasqueAPIBase = "https://api.cloudflareclient.com/v0a4471"
+
+const warpMasqueClientVer = "a-6.35-4471"
+
 func (s *WarpService) GetWarpData() (string, error) {
 	return s.GetWarp()
 }
@@ -134,6 +147,119 @@ func (s *WarpService) RegWarp(secretKey string, publicKey string) (string, error
 	return string(result), nil
 }
 
+// RegWarpMasque registers a WARP device of its own and enrolls an ECDSA P-256 key for
+// MASQUE: enrolling replaces a device's WireGuard key, so the stored one stays untouched.
+func (s *WarpService) RegWarpMasque() (string, error) {
+	_, wgPublicKey, err := wireguard.GenerateWireguardKeypair()
+	if err != nil {
+		return "", err
+	}
+	hostName, _ := os.Hostname()
+	regBody, err := json.Marshal(map[string]any{
+		"key":   wgPublicKey,
+		"tos":   time.Now().UTC().Format("2006-01-02T15:04:05.000Z"),
+		"type":  "PC",
+		"model": "x-ui",
+		"name":  hostName,
+	})
+	if err != nil {
+		return "", err
+	}
+	var registration struct {
+		ID    string `json:"id"`
+		Token string `json:"token"`
+	}
+	if err := s.doWarpMasqueRequest(http.MethodPost, warpMasqueAPIBase+"/reg", "", regBody, &registration); err != nil {
+		return "", err
+	}
+	if registration.ID == "" || registration.Token == "" {
+		return "", common.NewError("warp masque register: missing 'id' or 'token' in response")
+	}
+
+	key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+	if err != nil {
+		return "", err
+	}
+	publicDER, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
+	if err != nil {
+		return "", err
+	}
+	enrollBody, err := json.Marshal(map[string]string{
+		"key":         base64.StdEncoding.EncodeToString(publicDER),
+		"key_type":    "secp256r1",
+		"tunnel_type": "masque",
+		"name":        hostName,
+	})
+	if err != nil {
+		return "", err
+	}
+	var enrolled struct {
+		Config struct {
+			Interface struct {
+				Addresses struct {
+					V4 string `json:"v4"`
+					V6 string `json:"v6"`
+				} `json:"addresses"`
+			} `json:"interface"`
+			Peers []struct {
+				PublicKey string `json:"public_key"`
+				Endpoint  struct {
+					V4 string `json:"v4"`
+				} `json:"endpoint"`
+			} `json:"peers"`
+		} `json:"config"`
+	}
+	if err := s.doWarpMasqueRequest(http.MethodPatch, warpMasqueAPIBase+"/reg/"+registration.ID, registration.Token, enrollBody, &enrolled); err != nil {
+		return "", err
+	}
+	if len(enrolled.Config.Peers) == 0 || enrolled.Config.Peers[0].PublicKey == "" {
+		return "", common.NewError("warp masque enroll: missing endpoint public key in response")
+	}
+	peer := enrolled.Config.Peers[0]
+	endpoint, _, err := net.SplitHostPort(peer.Endpoint.V4)
+	if err != nil {
+		endpoint = peer.Endpoint.V4
+	}
+	privateDER, err := x509.MarshalPKCS8PrivateKey(key)
+	if err != nil {
+		return "", err
+	}
+	var address []string
+	for _, a := range []string{enrolled.Config.Interface.Addresses.V4, enrolled.Config.Interface.Addresses.V6} {
+		if a != "" {
+			address = append(address, a)
+		}
+	}
+	result, err := json.MarshalIndent(map[string]any{
+		"privateKey": string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privateDER})),
+		"publicKey":  peer.PublicKey,
+		"address":    address,
+		"endpoint":   endpoint,
+	}, "", "  ")
+	if err != nil {
+		return "", err
+	}
+	return string(result), nil
+}
+
+func (s *WarpService) doWarpMasqueRequest(method, url, token string, body []byte, out any) error {
+	req, err := http.NewRequestWithContext(context.Background(), method, url, bytes.NewReader(body))
+	if err != nil {
+		return err
+	}
+	req.Header.Set("CF-Client-Version", warpMasqueClientVer)
+	req.Header.Set("User-Agent", "WARP for Android")
+	req.Header.Set("Content-Type", "application/json")
+	if token != "" {
+		req.Header.Set("Authorization", "Bearer "+token)
+	}
+	respBody, err := s.doWarpRequest(req)
+	if err != nil {
+		return err
+	}
+	return json.Unmarshal(respBody, out)
+}
+
 func (s *WarpService) SetWarpLicense(license string) (string, error) {
 	warpData, err := s.loadWarpCreds()
 	if err != nil {

+ 136 - 0
internal/web/service/integration/warp_masque_test.go

@@ -0,0 +1,136 @@
+package integration
+
+import (
+	"crypto/ecdsa"
+	"crypto/elliptic"
+	"crypto/rand"
+	"crypto/x509"
+	"encoding/base64"
+	"encoding/json"
+	"encoding/pem"
+	"net/http"
+	"net/http/httptest"
+	"path/filepath"
+	"testing"
+
+	"github.com/xtls/xray-core/infra/conf"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func withWarpMasqueAPIBase(t *testing.T, base string) {
+	t.Helper()
+	orig := warpMasqueAPIBase
+	warpMasqueAPIBase = base
+	t.Cleanup(func() { warpMasqueAPIBase = orig })
+}
+
+// Enrolling an ECDSA key replaces a device's WireGuard key, so MASQUE gets a device of
+// its own; the returned warp block must pair the enrolled key and load in xray-core.
+func TestRegWarpMasqueEnrollsASeparateP256Device(t *testing.T) {
+	dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db"))
+	seedWarp(t, "")
+
+	endpointKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+	if err != nil {
+		t.Fatalf("generate endpoint key: %v", err)
+	}
+	endpointDER, err := x509.MarshalPKIXPublicKey(&endpointKey.PublicKey)
+	if err != nil {
+		t.Fatalf("marshal endpoint key: %v", err)
+	}
+	endpointPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: endpointDER}))
+
+	var enrolledKey *ecdsa.PublicKey
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		switch {
+		case r.Method == http.MethodPost && r.URL.Path == "/reg":
+			_, _ = w.Write([]byte(`{"id":"masque-device","token":"masque-token","account":{"license":""}}`))
+		case r.Method == http.MethodPatch && r.URL.Path == "/reg/masque-device":
+			if got := r.Header.Get("Authorization"); got != "Bearer masque-token" {
+				t.Errorf("enroll Authorization = %q, want the new device's token", got)
+			}
+			var body map[string]string
+			_ = json.NewDecoder(r.Body).Decode(&body)
+			if body["key_type"] != "secp256r1" || body["tunnel_type"] != "masque" {
+				t.Errorf("enroll body = %v, want a secp256r1 key for the masque tunnel", body)
+			}
+			der, err := base64.StdEncoding.DecodeString(body["key"])
+			if err != nil {
+				t.Errorf("enroll key is not base64: %v", err)
+			}
+			parsed, err := x509.ParsePKIXPublicKey(der)
+			key, ok := parsed.(*ecdsa.PublicKey)
+			if err != nil || !ok || key.Curve != elliptic.P256() {
+				t.Errorf("enroll key is not a PKIX P-256 public key: %v", err)
+			}
+			enrolledKey = key
+			resp, _ := json.Marshal(map[string]any{
+				"id": "masque-device",
+				"config": map[string]any{
+					"interface": map[string]any{"addresses": map[string]any{"v4": "172.16.0.2", "v6": "2606:4700:110:8a36::2"}},
+					"peers": []any{map[string]any{
+						"public_key": endpointPEM,
+						"endpoint":   map[string]any{"v4": "162.159.198.1:0", "v6": "[2606:4700:103::1]:0"},
+					}},
+				},
+			})
+			_, _ = w.Write(resp)
+		default:
+			t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
+			w.WriteHeader(http.StatusNotFound)
+		}
+	}))
+	t.Cleanup(srv.Close)
+	withWarpMasqueAPIBase(t, srv.URL)
+
+	out, err := (&WarpService{}).RegWarpMasque()
+	if err != nil {
+		t.Fatalf("RegWarpMasque: %v", err)
+	}
+	var result struct {
+		PrivateKey string   `json:"privateKey"`
+		PublicKey  string   `json:"publicKey"`
+		Address    []string `json:"address"`
+		Endpoint   string   `json:"endpoint"`
+	}
+	if err := json.Unmarshal([]byte(out), &result); err != nil {
+		t.Fatalf("decode result: %v", err)
+	}
+	if result.Endpoint != "162.159.198.1" || result.PublicKey != endpointPEM {
+		t.Fatalf("endpoint = %q, publicKey = %q; want the enrolled peer's", result.Endpoint, result.PublicKey)
+	}
+
+	block, _ := pem.Decode([]byte(result.PrivateKey))
+	if block == nil {
+		t.Fatalf("privateKey %q is not PEM", result.PrivateKey)
+	}
+	private, err := x509.ParsePKCS8PrivateKey(block.Bytes)
+	ecPrivate, ok := private.(*ecdsa.PrivateKey)
+	if err != nil || !ok || enrolledKey == nil || !ecPrivate.PublicKey.Equal(enrolledKey) {
+		t.Fatal("the returned private key does not match the enrolled public key")
+	}
+
+	raw, _ := json.Marshal(map[string]any{"warp": map[string]any{
+		"privateKey": result.PrivateKey, "publicKey": result.PublicKey, "address": result.Address,
+	}})
+	var masque conf.MasqueConfig
+	if err := json.Unmarshal(raw, &masque); err != nil {
+		t.Fatalf("decode masque config: %v", err)
+	}
+	if _, err := masque.Build(); err != nil {
+		t.Fatalf("xray-core refuses the returned warp block: %v", err)
+	}
+
+	var stored model.Setting
+	if err := database.GetDB().Where("key = ?", "warp").First(&stored).Error; err != nil {
+		t.Fatalf("reload warp setting: %v", err)
+	}
+	var data map[string]string
+	_ = json.Unmarshal([]byte(stored.Value), &data)
+	if data["device_id"] != "old-device" {
+		t.Fatalf("stored WireGuard registration = %v, want it untouched", data)
+	}
+}

+ 3 - 1
internal/web/translation/ar-EG.json

@@ -2229,7 +2229,9 @@
         "warpPlusData": "بيانات WARP+",
         "quota": "الحصة",
         "usage": "الاستخدام",
-        "addOutbound": "إضافة صادر"
+        "addOutbound": "إضافة صادر",
+        "masqueDesc": "يسجّل جهاز WARP منفصلًا مخصصًا لـ MASQUE؛ ولا يتأثر تسجيل WireGuard أعلاه.",
+        "addMasqueOutbound": "إضافة outbound لـ WARP عبر MASQUE"
       },
       "dns": {
         "enable": "فعل DNS",

+ 3 - 1
internal/web/translation/en-US.json

@@ -2347,7 +2347,9 @@
         "warpPlusData": "WARP+ data",
         "quota": "Quota",
         "usage": "Usage",
-        "addOutbound": "Add outbound"
+        "addOutbound": "Add outbound",
+        "masqueDesc": "Registers a separate WARP device enrolled for MASQUE; the WireGuard registration above is not affected.",
+        "addMasqueOutbound": "Add WARP over MASQUE outbound"
       },
       "dns": {
         "enable": "Enable DNS",

+ 3 - 1
internal/web/translation/es-ES.json

@@ -2229,7 +2229,9 @@
         "warpPlusData": "Datos WARP+",
         "quota": "Cuota",
         "usage": "Uso",
-        "addOutbound": "Añadir salida"
+        "addOutbound": "Añadir salida",
+        "masqueDesc": "Registra un dispositivo WARP aparte inscrito para MASQUE; el registro de WireGuard de arriba no se ve afectado.",
+        "addMasqueOutbound": "Añadir outbound de WARP sobre MASQUE"
       },
       "dns": {
         "enable": "Habilitar DNS",

+ 3 - 1
internal/web/translation/fa-IR.json

@@ -2229,7 +2229,9 @@
         "warpPlusData": "داده WARP+",
         "quota": "سهمیه",
         "usage": "مصرف",
-        "addOutbound": "افزودن خروجی"
+        "addOutbound": "افزودن خروجی",
+        "masqueDesc": "یک دستگاه WARP جداگانه برای MASQUE ثبت می‌کند؛ ثبت WireGuard بالا تغییری نمی‌کند.",
+        "addMasqueOutbound": "افزودن outbound برای WARP روی MASQUE"
       },
       "dns": {
         "enable": "فعال کردن حل دامنه",

+ 3 - 1
internal/web/translation/id-ID.json

@@ -2229,7 +2229,9 @@
         "warpPlusData": "Data WARP+",
         "quota": "Kuota",
         "usage": "Penggunaan",
-        "addOutbound": "Tambah outbound"
+        "addOutbound": "Tambah outbound",
+        "masqueDesc": "Mendaftarkan perangkat WARP terpisah untuk MASQUE; pendaftaran WireGuard di atas tidak terpengaruh.",
+        "addMasqueOutbound": "Tambah outbound WARP melalui MASQUE"
       },
       "dns": {
         "enable": "Aktifkan DNS",

+ 3 - 1
internal/web/translation/ja-JP.json

@@ -2229,7 +2229,9 @@
         "warpPlusData": "WARP+ データ",
         "quota": "クォータ",
         "usage": "使用量",
-        "addOutbound": "アウトバウンドを追加"
+        "addOutbound": "アウトバウンドを追加",
+        "masqueDesc": "MASQUE 用に別の WARP デバイスを登録します。上の WireGuard の登録には影響しません。",
+        "addMasqueOutbound": "WARP over MASQUE のアウトバウンドを追加"
       },
       "dns": {
         "enable": "DNSを有効にする",

+ 3 - 1
internal/web/translation/pt-BR.json

@@ -2229,7 +2229,9 @@
         "warpPlusData": "Dados WARP+",
         "quota": "Quota",
         "usage": "Uso",
-        "addOutbound": "Adicionar saída"
+        "addOutbound": "Adicionar saída",
+        "masqueDesc": "Registra um dispositivo WARP separado inscrito para MASQUE; o registro WireGuard acima não é afetado.",
+        "addMasqueOutbound": "Adicionar outbound WARP via MASQUE"
       },
       "dns": {
         "enable": "Ativar DNS",

+ 3 - 1
internal/web/translation/ru-RU.json

@@ -2229,7 +2229,9 @@
         "warpPlusData": "WARP+ data",
         "quota": "Квота",
         "usage": "Использование",
-        "addOutbound": "Добавить исходящий"
+        "addOutbound": "Добавить исходящий",
+        "masqueDesc": "Регистрирует отдельное устройство WARP для MASQUE; регистрация WireGuard выше не затрагивается.",
+        "addMasqueOutbound": "Добавить аутбаунд WARP через MASQUE"
       },
       "dns": {
         "enable": "Включить DNS",

+ 3 - 1
internal/web/translation/tr-TR.json

@@ -2229,7 +2229,9 @@
         "warpPlusData": "WARP+ Veri",
         "quota": "Kota",
         "usage": "Kullanım",
-        "addOutbound": "Giden Bağlantı Ekle"
+        "addOutbound": "Giden Bağlantı Ekle",
+        "masqueDesc": "MASQUE için ayrı bir WARP cihazı kaydeder; yukarıdaki WireGuard kaydı etkilenmez.",
+        "addMasqueOutbound": "MASQUE üzerinden WARP outbound ekle"
       },
       "dns": {
         "enable": "DNS'yi Etkinleştir",

+ 3 - 1
internal/web/translation/uk-UA.json

@@ -2229,7 +2229,9 @@
         "warpPlusData": "WARP+ data",
         "quota": "Квота",
         "usage": "Використання",
-        "addOutbound": "Додати вихідний"
+        "addOutbound": "Додати вихідний",
+        "masqueDesc": "Реєструє окремий пристрій WARP для MASQUE; реєстрація WireGuard вище не змінюється.",
+        "addMasqueOutbound": "Додати аутбаунд WARP через MASQUE"
       },
       "dns": {
         "enable": "Увімкнути DNS",

+ 3 - 1
internal/web/translation/vi-VN.json

@@ -2229,7 +2229,9 @@
         "warpPlusData": "Dữ liệu WARP+",
         "quota": "Hạn ngạch",
         "usage": "Sử dụng",
-        "addOutbound": "Thêm outbound"
+        "addOutbound": "Thêm outbound",
+        "masqueDesc": "Đăng ký một thiết bị WARP riêng cho MASQUE; đăng ký WireGuard ở trên không bị ảnh hưởng.",
+        "addMasqueOutbound": "Thêm outbound WARP qua MASQUE"
       },
       "dns": {
         "enable": "Kích hoạt DNS",

+ 3 - 1
internal/web/translation/zh-CN.json

@@ -2229,7 +2229,9 @@
         "warpPlusData": "WARP+ 数据",
         "quota": "配额",
         "usage": "使用",
-        "addOutbound": "添加出站"
+        "addOutbound": "添加出站",
+        "masqueDesc": "为 MASQUE 单独注册一个 WARP 设备;上方的 WireGuard 注册不受影响。",
+        "addMasqueOutbound": "添加 WARP over MASQUE 出站"
       },
       "dns": {
         "enable": "启用 DNS",

+ 3 - 1
internal/web/translation/zh-TW.json

@@ -2229,7 +2229,9 @@
         "warpPlusData": "WARP+ 資料",
         "quota": "配額",
         "usage": "使用",
-        "addOutbound": "新增出站"
+        "addOutbound": "新增出站",
+        "masqueDesc": "為 MASQUE 另外註冊一個 WARP 裝置;上方的 WireGuard 註冊不受影響。",
+        "addMasqueOutbound": "新增 WARP over MASQUE 出站"
       },
       "dns": {
         "enable": "啟用 DNS",