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:
MHSanaei
2026-10-11 10:31:01 +02:00
parent b04039c2bd
commit 04cccef311
25 changed files with 477 additions and 16 deletions
+2
View File
@@ -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
View File
@@ -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 {
@@ -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
View File
@@ -2229,7 +2229,9 @@
"warpPlusData": "بيانات WARP+",
"quota": "الحصة",
"usage": "الاستخدام",
"addOutbound": "إضافة صادر"
"addOutbound": "إضافة صادر",
"masqueDesc": "يسجّل جهاز WARP منفصلًا مخصصًا لـ MASQUE؛ ولا يتأثر تسجيل WireGuard أعلاه.",
"addMasqueOutbound": "إضافة outbound لـ WARP عبر MASQUE"
},
"dns": {
"enable": "فعل DNS",
+3 -1
View File
@@ -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
View File
@@ -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
View File
@@ -2229,7 +2229,9 @@
"warpPlusData": "داده WARP+",
"quota": "سهمیه",
"usage": "مصرف",
"addOutbound": "افزودن خروجی"
"addOutbound": "افزودن خروجی",
"masqueDesc": "یک دستگاه WARP جداگانه برای MASQUE ثبت می‌کند؛ ثبت WireGuard بالا تغییری نمی‌کند.",
"addMasqueOutbound": "افزودن outbound برای WARP روی MASQUE"
},
"dns": {
"enable": "فعال کردن حل دامنه",
+3 -1
View File
@@ -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
View File
@@ -2229,7 +2229,9 @@
"warpPlusData": "WARP+ データ",
"quota": "クォータ",
"usage": "使用量",
"addOutbound": "アウトバウンドを追加"
"addOutbound": "アウトバウンドを追加",
"masqueDesc": "MASQUE 用に別の WARP デバイスを登録します。上の WireGuard の登録には影響しません。",
"addMasqueOutbound": "WARP over MASQUE のアウトバウンドを追加"
},
"dns": {
"enable": "DNSを有効にする",
+3 -1
View File
@@ -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
View File
@@ -2229,7 +2229,9 @@
"warpPlusData": "WARP+ data",
"quota": "Квота",
"usage": "Использование",
"addOutbound": "Добавить исходящий"
"addOutbound": "Добавить исходящий",
"masqueDesc": "Регистрирует отдельное устройство WARP для MASQUE; регистрация WireGuard выше не затрагивается.",
"addMasqueOutbound": "Добавить аутбаунд WARP через MASQUE"
},
"dns": {
"enable": "Включить DNS",
+3 -1
View File
@@ -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
View File
@@ -2229,7 +2229,9 @@
"warpPlusData": "WARP+ data",
"quota": "Квота",
"usage": "Використання",
"addOutbound": "Додати вихідний"
"addOutbound": "Додати вихідний",
"masqueDesc": "Реєструє окремий пристрій WARP для MASQUE; реєстрація WireGuard вище не змінюється.",
"addMasqueOutbound": "Додати аутбаунд WARP через MASQUE"
},
"dns": {
"enable": "Увімкнути DNS",
+3 -1
View File
@@ -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
View File
@@ -2229,7 +2229,9 @@
"warpPlusData": "WARP+ 数据",
"quota": "配额",
"usage": "使用",
"addOutbound": "添加出站"
"addOutbound": "添加出站",
"masqueDesc": "为 MASQUE 单独注册一个 WARP 设备;上方的 WireGuard 注册不受影响。",
"addMasqueOutbound": "添加 WARP over MASQUE 出站"
},
"dns": {
"enable": "启用 DNS",
+3 -1
View File
@@ -2229,7 +2229,9 @@
"warpPlusData": "WARP+ 資料",
"quota": "配額",
"usage": "使用",
"addOutbound": "新增出站"
"addOutbound": "新增出站",
"masqueDesc": "為 MASQUE 另外註冊一個 WARP 裝置;上方的 WireGuard 註冊不受影響。",
"addMasqueOutbound": "新增 WARP over MASQUE 出站"
},
"dns": {
"enable": "啟用 DNS",