diff --git a/docs/content/docs/en/config/masque.mdx b/docs/content/docs/en/config/masque.mdx index ef7cca0c9..ec5ce137d 100644 --- a/docs/content/docs/en/config/masque.mdx +++ b/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: diff --git a/docs/content/docs/fa/config/masque.mdx b/docs/content/docs/fa/config/masque.mdx index 01dbdcb52..9b0feb58e 100644 --- a/docs/content/docs/fa/config/masque.mdx +++ b/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 ثبت شده باشد: diff --git a/docs/content/docs/ru/config/masque.mdx b/docs/content/docs/ru/config/masque.mdx index 7a5f2519c..3872b7233 100644 --- a/docs/content/docs/ru/config/masque.mdx +++ b/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: diff --git a/docs/content/docs/zh/config/masque.mdx b/docs/content/docs/zh/config/masque.mdx index 2316f1af5..604b121ea 100644 --- a/docs/content/docs/zh/config/masque.mdx +++ b/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 账户: diff --git a/docs/public/openapi.json b/docs/public/openapi.json index 43feed637..749ec2788 100644 --- a/docs/public/openapi.json +++ b/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" } diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 43feed637..749ec2788 100644 --- a/frontend/public/openapi.json +++ b/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" } diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index a0964c959..438b8037b 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/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', diff --git a/frontend/src/pages/xray/overrides/WarpModal.tsx b/frontend/src/pages/xray/overrides/WarpModal.tsx index 9c3aa36f3..a47f2e89b 100644 --- a/frontend/src/pages/xray/overrides/WarpModal.tsx +++ b/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 { + 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 | undefined, data: WarpData | null, @@ -310,6 +335,21 @@ export default function WarpModal({ } } + async function addMasqueOutbound() { + setLoading(true); + try { + const msg = await HttpUtil.post('/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({ )} )} + + MASQUE + + diff --git a/frontend/src/test/warp-masque-modal.test.tsx b/frontend/src/test/warp-masque-modal.test.tsx new file mode 100644 index 000000000..d440d4e7d --- /dev/null +++ b/frontend/src/test/warp-masque-modal.test.tsx @@ -0,0 +1,100 @@ +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { act, fireEvent, screen, waitFor } from '@testing-library/react'; + +import WarpModal from '@/pages/xray/overrides/WarpModal'; +import { HttpUtil, Msg } from '@/utils'; +import { renderWithProviders } from './test-utils'; + +const registration = { + privateKey: '-----BEGIN PRIVATE KEY-----\nAAA\n-----END PRIVATE KEY-----\n', + publicKey: '-----BEGIN PUBLIC KEY-----\nBBB\n-----END PUBLIC KEY-----\n', + address: ['172.16.0.2', '2606:4700:110:8a36::2'], + endpoint: '162.159.198.1', +}; + +function mockApi() { + vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => { + if (url === '/panel/api/xray/warp/regMasque') { + return new Msg(true, '', JSON.stringify(registration)); + } + if (url === '/panel/api/setting/all') return new Msg(true, '', {}); + return new Msg(true, '', ''); + }); +} + +async function clickAddMasque() { + const button = await screen.findByRole('button', { name: /Add WARP over MASQUE outbound/ }); + // The modal loads its WARP data on open; a loading button swallows the click. + await waitFor(() => expect(button.className).not.toContain('ant-btn-loading')); + await act(async () => { + fireEvent.click(button); + }); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// WARP over MASQUE dials Cloudflare's MASQUE endpoint with the enrolled key, its SNI +// and HTTP/3; an outbound missing any of them fails the handshake at runtime. +describe('WarpModal WARP over MASQUE', () => { + const expected = { + tag: 'warp-masque', + protocol: 'masque', + settings: { address: '162.159.198.1', port: 443 }, + streamSettings: { + network: 'masque', + security: 'tls', + tlsSettings: { serverName: 'consumer-masque.cloudflareclient.com', alpn: ['h3'] }, + masqueSettings: { + warp: { + privateKey: registration.privateKey, + publicKey: registration.publicKey, + address: registration.address, + }, + }, + }, + }; + + it('adds a MASQUE outbound built from a fresh enrollment', async () => { + mockApi(); + const onAddOutbound = vi.fn(); + renderWithProviders( + {}} + onAddOutbound={onAddOutbound} + onResetOutbound={() => {}} + onRemoveOutbound={() => {}} + />, + ); + + await clickAddMasque(); + + await waitFor(() => expect(onAddOutbound).toHaveBeenCalledWith(expected)); + }); + + it('replaces an existing warp-masque outbound instead of adding a second one', async () => { + mockApi(); + const onAddOutbound = vi.fn(); + const onResetOutbound = vi.fn(); + renderWithProviders( + {}} + onAddOutbound={onAddOutbound} + onResetOutbound={onResetOutbound} + onRemoveOutbound={() => {}} + />, + ); + + await clickAddMasque(); + + await waitFor(() => + expect(onResetOutbound).toHaveBeenCalledWith({ index: 1, outbound: expected }), + ); + expect(onAddOutbound).not.toHaveBeenCalled(); + }); +}); diff --git a/internal/web/controller/xray_setting.go b/internal/web/controller/xray_setting.go index ca8b9ac72..16ea5fb36 100644 --- a/internal/web/controller/xray_setting.go +++ b/internal/web/controller/xray_setting.go @@ -195,6 +195,8 @@ func (a *XraySettingController) warp(c *gin.Context) { skey := c.PostForm("privateKey") pkey := c.PostForm("publicKey") resp, err = a.WarpService.RegWarp(skey, pkey) + case "regMasque": + resp, err = a.WarpService.RegWarpMasque() case "changeIp": resp, err = a.WarpService.ChangeWarpIP() if err == nil { diff --git a/internal/web/service/integration/warp.go b/internal/web/service/integration/warp.go index 86e7d6afc..4c77e8f42 100644 --- a/internal/web/service/integration/warp.go +++ b/internal/web/service/integration/warp.go @@ -3,9 +3,16 @@ package integration import ( "bytes" "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/base64" "encoding/json" + "encoding/pem" "fmt" "io" + "net" "net/http" "os" "time" @@ -30,6 +37,12 @@ const ( // (not a const) so integration tests can point it at a mock server. var warpAPIBase = "https://api.cloudflareclient.com/v0a4005" +// warpMasqueAPIBase and warpMasqueClientVer are the API revision and client the WARP +// apps enroll secp256r1 MASQUE keys with; a var so tests can point it at a mock. +var warpMasqueAPIBase = "https://api.cloudflareclient.com/v0a4471" + +const warpMasqueClientVer = "a-6.35-4471" + func (s *WarpService) GetWarpData() (string, error) { return s.GetWarp() } @@ -134,6 +147,119 @@ func (s *WarpService) RegWarp(secretKey string, publicKey string) (string, error return string(result), nil } +// RegWarpMasque registers a WARP device of its own and enrolls an ECDSA P-256 key for +// MASQUE: enrolling replaces a device's WireGuard key, so the stored one stays untouched. +func (s *WarpService) RegWarpMasque() (string, error) { + _, wgPublicKey, err := wireguard.GenerateWireguardKeypair() + if err != nil { + return "", err + } + hostName, _ := os.Hostname() + regBody, err := json.Marshal(map[string]any{ + "key": wgPublicKey, + "tos": time.Now().UTC().Format("2006-01-02T15:04:05.000Z"), + "type": "PC", + "model": "x-ui", + "name": hostName, + }) + if err != nil { + return "", err + } + var registration struct { + ID string `json:"id"` + Token string `json:"token"` + } + if err := s.doWarpMasqueRequest(http.MethodPost, warpMasqueAPIBase+"/reg", "", regBody, ®istration); err != nil { + return "", err + } + if registration.ID == "" || registration.Token == "" { + return "", common.NewError("warp masque register: missing 'id' or 'token' in response") + } + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return "", err + } + publicDER, err := x509.MarshalPKIXPublicKey(&key.PublicKey) + if err != nil { + return "", err + } + enrollBody, err := json.Marshal(map[string]string{ + "key": base64.StdEncoding.EncodeToString(publicDER), + "key_type": "secp256r1", + "tunnel_type": "masque", + "name": hostName, + }) + if err != nil { + return "", err + } + var enrolled struct { + Config struct { + Interface struct { + Addresses struct { + V4 string `json:"v4"` + V6 string `json:"v6"` + } `json:"addresses"` + } `json:"interface"` + Peers []struct { + PublicKey string `json:"public_key"` + Endpoint struct { + V4 string `json:"v4"` + } `json:"endpoint"` + } `json:"peers"` + } `json:"config"` + } + if err := s.doWarpMasqueRequest(http.MethodPatch, warpMasqueAPIBase+"/reg/"+registration.ID, registration.Token, enrollBody, &enrolled); err != nil { + return "", err + } + if len(enrolled.Config.Peers) == 0 || enrolled.Config.Peers[0].PublicKey == "" { + return "", common.NewError("warp masque enroll: missing endpoint public key in response") + } + peer := enrolled.Config.Peers[0] + endpoint, _, err := net.SplitHostPort(peer.Endpoint.V4) + if err != nil { + endpoint = peer.Endpoint.V4 + } + privateDER, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + return "", err + } + var address []string + for _, a := range []string{enrolled.Config.Interface.Addresses.V4, enrolled.Config.Interface.Addresses.V6} { + if a != "" { + address = append(address, a) + } + } + result, err := json.MarshalIndent(map[string]any{ + "privateKey": string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privateDER})), + "publicKey": peer.PublicKey, + "address": address, + "endpoint": endpoint, + }, "", " ") + if err != nil { + return "", err + } + return string(result), nil +} + +func (s *WarpService) doWarpMasqueRequest(method, url, token string, body []byte, out any) error { + req, err := http.NewRequestWithContext(context.Background(), method, url, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("CF-Client-Version", warpMasqueClientVer) + req.Header.Set("User-Agent", "WARP for Android") + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + respBody, err := s.doWarpRequest(req) + if err != nil { + return err + } + return json.Unmarshal(respBody, out) +} + func (s *WarpService) SetWarpLicense(license string) (string, error) { warpData, err := s.loadWarpCreds() if err != nil { diff --git a/internal/web/service/integration/warp_masque_test.go b/internal/web/service/integration/warp_masque_test.go new file mode 100644 index 000000000..7f2953f3d --- /dev/null +++ b/internal/web/service/integration/warp_masque_test.go @@ -0,0 +1,136 @@ +package integration + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/xtls/xray-core/infra/conf" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/dbtest" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +func withWarpMasqueAPIBase(t *testing.T, base string) { + t.Helper() + orig := warpMasqueAPIBase + warpMasqueAPIBase = base + t.Cleanup(func() { warpMasqueAPIBase = orig }) +} + +// Enrolling an ECDSA key replaces a device's WireGuard key, so MASQUE gets a device of +// its own; the returned warp block must pair the enrolled key and load in xray-core. +func TestRegWarpMasqueEnrollsASeparateP256Device(t *testing.T) { + dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db")) + seedWarp(t, "") + + endpointKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate endpoint key: %v", err) + } + endpointDER, err := x509.MarshalPKIXPublicKey(&endpointKey.PublicKey) + if err != nil { + t.Fatalf("marshal endpoint key: %v", err) + } + endpointPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: endpointDER})) + + var enrolledKey *ecdsa.PublicKey + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/reg": + _, _ = w.Write([]byte(`{"id":"masque-device","token":"masque-token","account":{"license":""}}`)) + case r.Method == http.MethodPatch && r.URL.Path == "/reg/masque-device": + if got := r.Header.Get("Authorization"); got != "Bearer masque-token" { + t.Errorf("enroll Authorization = %q, want the new device's token", got) + } + var body map[string]string + _ = json.NewDecoder(r.Body).Decode(&body) + if body["key_type"] != "secp256r1" || body["tunnel_type"] != "masque" { + t.Errorf("enroll body = %v, want a secp256r1 key for the masque tunnel", body) + } + der, err := base64.StdEncoding.DecodeString(body["key"]) + if err != nil { + t.Errorf("enroll key is not base64: %v", err) + } + parsed, err := x509.ParsePKIXPublicKey(der) + key, ok := parsed.(*ecdsa.PublicKey) + if err != nil || !ok || key.Curve != elliptic.P256() { + t.Errorf("enroll key is not a PKIX P-256 public key: %v", err) + } + enrolledKey = key + resp, _ := json.Marshal(map[string]any{ + "id": "masque-device", + "config": map[string]any{ + "interface": map[string]any{"addresses": map[string]any{"v4": "172.16.0.2", "v6": "2606:4700:110:8a36::2"}}, + "peers": []any{map[string]any{ + "public_key": endpointPEM, + "endpoint": map[string]any{"v4": "162.159.198.1:0", "v6": "[2606:4700:103::1]:0"}, + }}, + }, + }) + _, _ = w.Write(resp) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(srv.Close) + withWarpMasqueAPIBase(t, srv.URL) + + out, err := (&WarpService{}).RegWarpMasque() + if err != nil { + t.Fatalf("RegWarpMasque: %v", err) + } + var result struct { + PrivateKey string `json:"privateKey"` + PublicKey string `json:"publicKey"` + Address []string `json:"address"` + Endpoint string `json:"endpoint"` + } + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("decode result: %v", err) + } + if result.Endpoint != "162.159.198.1" || result.PublicKey != endpointPEM { + t.Fatalf("endpoint = %q, publicKey = %q; want the enrolled peer's", result.Endpoint, result.PublicKey) + } + + block, _ := pem.Decode([]byte(result.PrivateKey)) + if block == nil { + t.Fatalf("privateKey %q is not PEM", result.PrivateKey) + } + private, err := x509.ParsePKCS8PrivateKey(block.Bytes) + ecPrivate, ok := private.(*ecdsa.PrivateKey) + if err != nil || !ok || enrolledKey == nil || !ecPrivate.PublicKey.Equal(enrolledKey) { + t.Fatal("the returned private key does not match the enrolled public key") + } + + raw, _ := json.Marshal(map[string]any{"warp": map[string]any{ + "privateKey": result.PrivateKey, "publicKey": result.PublicKey, "address": result.Address, + }}) + var masque conf.MasqueConfig + if err := json.Unmarshal(raw, &masque); err != nil { + t.Fatalf("decode masque config: %v", err) + } + if _, err := masque.Build(); err != nil { + t.Fatalf("xray-core refuses the returned warp block: %v", err) + } + + var stored model.Setting + if err := database.GetDB().Where("key = ?", "warp").First(&stored).Error; err != nil { + t.Fatalf("reload warp setting: %v", err) + } + var data map[string]string + _ = json.Unmarshal([]byte(stored.Value), &data) + if data["device_id"] != "old-device" { + t.Fatalf("stored WireGuard registration = %v, want it untouched", data) + } +} diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index f1e5aa9f2..47f00497b 100644 --- a/internal/web/translation/ar-EG.json +++ b/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", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index 307c591ca..f1fbf4a17 100644 --- a/internal/web/translation/en-US.json +++ b/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", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index 118033782..02f29b1c0 100644 --- a/internal/web/translation/es-ES.json +++ b/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", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index 4004bd407..9e7f2ac04 100644 --- a/internal/web/translation/fa-IR.json +++ b/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": "فعال کردن حل دامنه", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index 2daeea7d9..8ac58554e 100644 --- a/internal/web/translation/id-ID.json +++ b/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", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index 36df3b947..98aee605f 100644 --- a/internal/web/translation/ja-JP.json +++ b/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を有効にする", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index 64143efe7..3b8e36dcf 100644 --- a/internal/web/translation/pt-BR.json +++ b/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", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index 855ccfc5b..a438036da 100644 --- a/internal/web/translation/ru-RU.json +++ b/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", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 6ecac8b1d..8ce5bac44 100644 --- a/internal/web/translation/tr-TR.json +++ b/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", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index 9584b2dc2..9ac1da9b8 100644 --- a/internal/web/translation/uk-UA.json +++ b/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", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index d263a9237..aba0dd526 100644 --- a/internal/web/translation/vi-VN.json +++ b/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", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index e7ab80592..2c6e8f1a2 100644 --- a/internal/web/translation/zh-CN.json +++ b/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", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 7bde6e021..859569342 100644 --- a/internal/web/translation/zh-TW.json +++ b/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",