mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-13 00:25:04 +03:00
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.
This commit is contained in:
@@ -40,6 +40,11 @@ Pick **masque** as the outbound protocol, then set:
|
|||||||
|
|
||||||
## WARP over MASQUE
|
## 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
|
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:
|
endpoint instead of WireGuard. It takes a WARP registration enrolled for MASQUE:
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ icon: Waypoints
|
|||||||
|
|
||||||
## WARP از طریق MASQUE
|
## WARP از طریق MASQUE
|
||||||
|
|
||||||
|
سادهترین راه **Xray ← Outbounds ← WARP ← افزودن outbound برای WARP روی MASQUE** است: پنل یک
|
||||||
|
دستگاه WARP جداگانه ثبت میکند، برای آن کلید MASQUE ثبت میکند و یک outbound آمادهٔ `warp-masque`
|
||||||
|
اضافه میکند (یا outbound موجود را تازه میکند). ثبت WARP وایرگارد شما دست نمیخورد.
|
||||||
|
برای راهاندازی دستی:
|
||||||
|
|
||||||
**WARP** را در transport مربوط به masque روشن کنید تا بهجای WireGuard از طریق endpoint
|
**WARP** را در transport مربوط به masque روشن کنید تا بهجای WireGuard از طریق endpoint
|
||||||
مخصوص MASQUE به Cloudflare WARP برسید. این کار به یک ثبتنام WARP نیاز دارد که برای MASQUE
|
مخصوص MASQUE به Cloudflare WARP برسید. این کار به یک ثبتنام WARP نیاز دارد که برای MASQUE
|
||||||
ثبت شده باشد:
|
ثبت شده باشد:
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ icon: Waypoints
|
|||||||
|
|
||||||
## WARP через MASQUE
|
## WARP через MASQUE
|
||||||
|
|
||||||
|
Проще всего: **Xray → Аутбаунды → WARP → Добавить аутбаунд WARP через MASQUE** — панель
|
||||||
|
регистрирует отдельное устройство WARP, привязывает к нему ключ MASQUE и добавляет готовый
|
||||||
|
аутбаунд `warp-masque` (или обновляет существующий). Регистрация WARP для WireGuard не меняется.
|
||||||
|
Для ручной настройки:
|
||||||
|
|
||||||
Включите **WARP** в транспорте masque, чтобы подключаться к Cloudflare WARP через его
|
Включите **WARP** в транспорте masque, чтобы подключаться к Cloudflare WARP через его
|
||||||
MASQUE-эндпоинт вместо WireGuard. Для этого нужна регистрация WARP, привязанная к MASQUE:
|
MASQUE-эндпоинт вместо WireGuard. Для этого нужна регистрация WARP, привязанная к MASQUE:
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ HTTPS 流量无异的完整三层隧道。xray-core 原生支持它;3x-ui 将
|
|||||||
|
|
||||||
## 通过 MASQUE 使用 WARP
|
## 通过 MASQUE 使用 WARP
|
||||||
|
|
||||||
|
最简单的方式是 **Xray → 出站 → WARP → 添加 WARP over MASQUE 出站**:面板会单独注册一个 WARP 设备、为其注册 MASQUE 密钥,
|
||||||
|
并添加一个可直接使用的 `warp-masque` 出站(若已存在则更新)。你的 WireGuard WARP 注册不受影响。
|
||||||
|
如需手动设置:
|
||||||
|
|
||||||
在 masque 传输中开启 **WARP**,即可通过 Cloudflare 的 MASQUE 端点而非 WireGuard 连接 WARP。这需要一个已为
|
在 masque 传输中开启 **WARP**,即可通过 Cloudflare 的 MASQUE 端点而非 WireGuard 连接 WARP。这需要一个已为
|
||||||
MASQUE 注册的 WARP 账户:
|
MASQUE 注册的 WARP 账户:
|
||||||
|
|
||||||
|
|||||||
@@ -13649,7 +13649,7 @@
|
|||||||
"name": "action",
|
"name": "action",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true,
|
"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": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13649,7 +13649,7 @@
|
|||||||
"name": "action",
|
"name": "action",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true,
|
"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": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2138,7 +2138,7 @@ export const sections: readonly Section[] = [
|
|||||||
name: 'action',
|
name: 'action',
|
||||||
in: 'path',
|
in: 'path',
|
||||||
type: 'string',
|
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',
|
name: 'privateKey',
|
||||||
|
|||||||
@@ -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(
|
export function mergeWarpRotation(
|
||||||
existing: Record<string, unknown> | undefined,
|
existing: Record<string, unknown> | undefined,
|
||||||
data: WarpData | null,
|
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() {
|
function addOutbound() {
|
||||||
if (!stagedOutbound) {
|
if (!stagedOutbound) {
|
||||||
messageApi.warning(t('pages.xray.warp.fetchFirst'));
|
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>
|
</FormProvider>
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -195,6 +195,8 @@ func (a *XraySettingController) warp(c *gin.Context) {
|
|||||||
skey := c.PostForm("privateKey")
|
skey := c.PostForm("privateKey")
|
||||||
pkey := c.PostForm("publicKey")
|
pkey := c.PostForm("publicKey")
|
||||||
resp, err = a.WarpService.RegWarp(skey, pkey)
|
resp, err = a.WarpService.RegWarp(skey, pkey)
|
||||||
|
case "regMasque":
|
||||||
|
resp, err = a.WarpService.RegWarpMasque()
|
||||||
case "changeIp":
|
case "changeIp":
|
||||||
resp, err = a.WarpService.ChangeWarpIP()
|
resp, err = a.WarpService.ChangeWarpIP()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -3,9 +3,16 @@ package integration
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
@@ -30,6 +37,12 @@ const (
|
|||||||
// (not a const) so integration tests can point it at a mock server.
|
// (not a const) so integration tests can point it at a mock server.
|
||||||
var warpAPIBase = "https://api.cloudflareclient.com/v0a4005"
|
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) {
|
func (s *WarpService) GetWarpData() (string, error) {
|
||||||
return s.GetWarp()
|
return s.GetWarp()
|
||||||
}
|
}
|
||||||
@@ -134,6 +147,119 @@ func (s *WarpService) RegWarp(secretKey string, publicKey string) (string, error
|
|||||||
return string(result), nil
|
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) {
|
func (s *WarpService) SetWarpLicense(license string) (string, error) {
|
||||||
warpData, err := s.loadWarpCreds()
|
warpData, err := s.loadWarpCreds()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2229,7 +2229,9 @@
|
|||||||
"warpPlusData": "بيانات WARP+",
|
"warpPlusData": "بيانات WARP+",
|
||||||
"quota": "الحصة",
|
"quota": "الحصة",
|
||||||
"usage": "الاستخدام",
|
"usage": "الاستخدام",
|
||||||
"addOutbound": "إضافة صادر"
|
"addOutbound": "إضافة صادر",
|
||||||
|
"masqueDesc": "يسجّل جهاز WARP منفصلًا مخصصًا لـ MASQUE؛ ولا يتأثر تسجيل WireGuard أعلاه.",
|
||||||
|
"addMasqueOutbound": "إضافة outbound لـ WARP عبر MASQUE"
|
||||||
},
|
},
|
||||||
"dns": {
|
"dns": {
|
||||||
"enable": "فعل DNS",
|
"enable": "فعل DNS",
|
||||||
|
|||||||
@@ -2347,7 +2347,9 @@
|
|||||||
"warpPlusData": "WARP+ data",
|
"warpPlusData": "WARP+ data",
|
||||||
"quota": "Quota",
|
"quota": "Quota",
|
||||||
"usage": "Usage",
|
"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": {
|
"dns": {
|
||||||
"enable": "Enable DNS",
|
"enable": "Enable DNS",
|
||||||
|
|||||||
@@ -2229,7 +2229,9 @@
|
|||||||
"warpPlusData": "Datos WARP+",
|
"warpPlusData": "Datos WARP+",
|
||||||
"quota": "Cuota",
|
"quota": "Cuota",
|
||||||
"usage": "Uso",
|
"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": {
|
"dns": {
|
||||||
"enable": "Habilitar DNS",
|
"enable": "Habilitar DNS",
|
||||||
|
|||||||
@@ -2229,7 +2229,9 @@
|
|||||||
"warpPlusData": "داده WARP+",
|
"warpPlusData": "داده WARP+",
|
||||||
"quota": "سهمیه",
|
"quota": "سهمیه",
|
||||||
"usage": "مصرف",
|
"usage": "مصرف",
|
||||||
"addOutbound": "افزودن خروجی"
|
"addOutbound": "افزودن خروجی",
|
||||||
|
"masqueDesc": "یک دستگاه WARP جداگانه برای MASQUE ثبت میکند؛ ثبت WireGuard بالا تغییری نمیکند.",
|
||||||
|
"addMasqueOutbound": "افزودن outbound برای WARP روی MASQUE"
|
||||||
},
|
},
|
||||||
"dns": {
|
"dns": {
|
||||||
"enable": "فعال کردن حل دامنه",
|
"enable": "فعال کردن حل دامنه",
|
||||||
|
|||||||
@@ -2229,7 +2229,9 @@
|
|||||||
"warpPlusData": "Data WARP+",
|
"warpPlusData": "Data WARP+",
|
||||||
"quota": "Kuota",
|
"quota": "Kuota",
|
||||||
"usage": "Penggunaan",
|
"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": {
|
"dns": {
|
||||||
"enable": "Aktifkan DNS",
|
"enable": "Aktifkan DNS",
|
||||||
|
|||||||
@@ -2229,7 +2229,9 @@
|
|||||||
"warpPlusData": "WARP+ データ",
|
"warpPlusData": "WARP+ データ",
|
||||||
"quota": "クォータ",
|
"quota": "クォータ",
|
||||||
"usage": "使用量",
|
"usage": "使用量",
|
||||||
"addOutbound": "アウトバウンドを追加"
|
"addOutbound": "アウトバウンドを追加",
|
||||||
|
"masqueDesc": "MASQUE 用に別の WARP デバイスを登録します。上の WireGuard の登録には影響しません。",
|
||||||
|
"addMasqueOutbound": "WARP over MASQUE のアウトバウンドを追加"
|
||||||
},
|
},
|
||||||
"dns": {
|
"dns": {
|
||||||
"enable": "DNSを有効にする",
|
"enable": "DNSを有効にする",
|
||||||
|
|||||||
@@ -2229,7 +2229,9 @@
|
|||||||
"warpPlusData": "Dados WARP+",
|
"warpPlusData": "Dados WARP+",
|
||||||
"quota": "Quota",
|
"quota": "Quota",
|
||||||
"usage": "Uso",
|
"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": {
|
"dns": {
|
||||||
"enable": "Ativar DNS",
|
"enable": "Ativar DNS",
|
||||||
|
|||||||
@@ -2229,7 +2229,9 @@
|
|||||||
"warpPlusData": "WARP+ data",
|
"warpPlusData": "WARP+ data",
|
||||||
"quota": "Квота",
|
"quota": "Квота",
|
||||||
"usage": "Использование",
|
"usage": "Использование",
|
||||||
"addOutbound": "Добавить исходящий"
|
"addOutbound": "Добавить исходящий",
|
||||||
|
"masqueDesc": "Регистрирует отдельное устройство WARP для MASQUE; регистрация WireGuard выше не затрагивается.",
|
||||||
|
"addMasqueOutbound": "Добавить аутбаунд WARP через MASQUE"
|
||||||
},
|
},
|
||||||
"dns": {
|
"dns": {
|
||||||
"enable": "Включить DNS",
|
"enable": "Включить DNS",
|
||||||
|
|||||||
@@ -2229,7 +2229,9 @@
|
|||||||
"warpPlusData": "WARP+ Veri",
|
"warpPlusData": "WARP+ Veri",
|
||||||
"quota": "Kota",
|
"quota": "Kota",
|
||||||
"usage": "Kullanım",
|
"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": {
|
"dns": {
|
||||||
"enable": "DNS'yi Etkinleştir",
|
"enable": "DNS'yi Etkinleştir",
|
||||||
|
|||||||
@@ -2229,7 +2229,9 @@
|
|||||||
"warpPlusData": "WARP+ data",
|
"warpPlusData": "WARP+ data",
|
||||||
"quota": "Квота",
|
"quota": "Квота",
|
||||||
"usage": "Використання",
|
"usage": "Використання",
|
||||||
"addOutbound": "Додати вихідний"
|
"addOutbound": "Додати вихідний",
|
||||||
|
"masqueDesc": "Реєструє окремий пристрій WARP для MASQUE; реєстрація WireGuard вище не змінюється.",
|
||||||
|
"addMasqueOutbound": "Додати аутбаунд WARP через MASQUE"
|
||||||
},
|
},
|
||||||
"dns": {
|
"dns": {
|
||||||
"enable": "Увімкнути DNS",
|
"enable": "Увімкнути DNS",
|
||||||
|
|||||||
@@ -2229,7 +2229,9 @@
|
|||||||
"warpPlusData": "Dữ liệu WARP+",
|
"warpPlusData": "Dữ liệu WARP+",
|
||||||
"quota": "Hạn ngạch",
|
"quota": "Hạn ngạch",
|
||||||
"usage": "Sử dụng",
|
"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": {
|
"dns": {
|
||||||
"enable": "Kích hoạt DNS",
|
"enable": "Kích hoạt DNS",
|
||||||
|
|||||||
@@ -2229,7 +2229,9 @@
|
|||||||
"warpPlusData": "WARP+ 数据",
|
"warpPlusData": "WARP+ 数据",
|
||||||
"quota": "配额",
|
"quota": "配额",
|
||||||
"usage": "使用",
|
"usage": "使用",
|
||||||
"addOutbound": "添加出站"
|
"addOutbound": "添加出站",
|
||||||
|
"masqueDesc": "为 MASQUE 单独注册一个 WARP 设备;上方的 WireGuard 注册不受影响。",
|
||||||
|
"addMasqueOutbound": "添加 WARP over MASQUE 出站"
|
||||||
},
|
},
|
||||||
"dns": {
|
"dns": {
|
||||||
"enable": "启用 DNS",
|
"enable": "启用 DNS",
|
||||||
|
|||||||
@@ -2229,7 +2229,9 @@
|
|||||||
"warpPlusData": "WARP+ 資料",
|
"warpPlusData": "WARP+ 資料",
|
||||||
"quota": "配額",
|
"quota": "配額",
|
||||||
"usage": "使用",
|
"usage": "使用",
|
||||||
"addOutbound": "新增出站"
|
"addOutbound": "新增出站",
|
||||||
|
"masqueDesc": "為 MASQUE 另外註冊一個 WARP 裝置;上方的 WireGuard 註冊不受影響。",
|
||||||
|
"addMasqueOutbound": "新增 WARP over MASQUE 出站"
|
||||||
},
|
},
|
||||||
"dns": {
|
"dns": {
|
||||||
"enable": "啟用 DNS",
|
"enable": "啟用 DNS",
|
||||||
|
|||||||
Reference in New Issue
Block a user