Files
3x-ui/web/service/warp.go
T
Rouzbeh† d9ccf157c3 feat: add manual and automatic WARP IP rotation (#5099)
* feat: add manual and automatic WARP IP rotation

* fix: update generated api and frontend schemas

* fix(warp): validate rotation interval, fix auto-update timing, sync editor

- Validate the auto-update interval as an integer and store it via setInt;
  a non-integer value previously broke GetAllSetting for the whole panel.
- Seed warpLastUpdate when the interval is saved and when changing IP
  manually, so auto-update counts from "now" instead of epoch 0 and a
  manual rotation doesn't trigger an immediate scheduled one.
- Guard WarpIpJob: when lastUpdate is unset, establish a baseline and skip
  instead of rotating on the next tick.
- Log WARP license re-apply failures instead of swallowing them.
- After a manual "Change IP", sync the in-memory Xray editor with the keys
  the backend persisted so a later template save can't revert them; only
  toast success when the interval save actually succeeds.
- Add the WARP rotation UI strings to all 13 locales.
- Drop trailing whitespace introduced in entity.go and xray_setting.go.

---------

Co-authored-by: Rqzbeh <Rqzbeh@example.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-06-09 01:43:43 +02:00

267 lines
6.5 KiB
Go

package service
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
"github.com/mhsanaei/3x-ui/v3/logger"
"github.com/mhsanaei/3x-ui/v3/util"
"github.com/mhsanaei/3x-ui/v3/util/common"
)
// WarpService provides business logic for Cloudflare WARP integration.
// It manages WARP configuration and connectivity settings.
type WarpService struct {
SettingService
}
const (
warpAPIBase = "https://api.cloudflareclient.com/v0a4005"
warpClientVer = "a-6.30-3596"
)
func (s *WarpService) GetWarpData() (string, error) {
return s.SettingService.GetWarp()
}
func (s *WarpService) DelWarpData() error {
return s.SettingService.SetWarp("")
}
func (s *WarpService) GetWarpConfig() (string, error) {
warpData, err := s.loadWarpCreds()
if err != nil {
return "", err
}
url := fmt.Sprintf("%s/reg/%s", warpAPIBase, warpData["device_id"])
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+warpData["access_token"])
body, err := s.doWarpRequest(req)
if err != nil {
return "", err
}
return string(body), nil
}
func (s *WarpService) RegWarp(secretKey string, publicKey string) (string, error) {
hostName, _ := os.Hostname()
reqBody, err := json.Marshal(map[string]any{
"key": publicKey,
"tos": time.Now().UTC().Format("2006-01-02T15:04:05.000Z"),
"type": "PC",
"model": "x-ui",
"name": hostName,
})
if err != nil {
return "", err
}
req, err := http.NewRequest(http.MethodPost, warpAPIBase+"/reg", bytes.NewReader(reqBody))
if err != nil {
return "", err
}
req.Header.Set("CF-Client-Version", warpClientVer)
req.Header.Set("Content-Type", "application/json")
body, err := s.doWarpRequest(req)
if err != nil {
return "", err
}
var rsp map[string]any
if err := json.Unmarshal(body, &rsp); err != nil {
return "", err
}
deviceID, ok := rsp["id"].(string)
if !ok {
return "", common.NewError("warp register: missing 'id' in response")
}
token, ok := rsp["token"].(string)
if !ok {
return "", common.NewError("warp register: missing 'token' in response")
}
account, ok := rsp["account"].(map[string]any)
if !ok {
return "", common.NewError("warp register: missing 'account' in response")
}
license, ok := account["license"].(string)
if !ok {
return "", common.NewError("warp register: missing 'account.license' in response")
}
warpData := map[string]string{
"access_token": token,
"device_id": deviceID,
"license_key": license,
"private_key": secretKey,
}
if config, ok := rsp["config"].(map[string]any); ok {
if clientID, ok := config["client_id"].(string); ok {
warpData["client_id"] = clientID
}
}
warpJSON, err := json.MarshalIndent(warpData, "", " ")
if err != nil {
return "", err
}
if err := s.SettingService.SetWarp(string(warpJSON)); err != nil {
return "", err
}
result, err := json.MarshalIndent(map[string]any{
"data": warpData,
"config": json.RawMessage(body),
}, "", " ")
if err != nil {
return "", err
}
return string(result), nil
}
func (s *WarpService) SetWarpLicense(license string) (string, error) {
warpData, err := s.loadWarpCreds()
if err != nil {
return "", err
}
url := fmt.Sprintf("%s/reg/%s/account", warpAPIBase, warpData["device_id"])
reqBody, err := json.Marshal(map[string]string{"license": license})
if err != nil {
return "", err
}
req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(reqBody))
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+warpData["access_token"])
req.Header.Set("Content-Type", "application/json")
body, err := s.doWarpRequest(req)
if err != nil {
return "", err
}
var response map[string]any
if err := json.Unmarshal(body, &response); err != nil {
return "", err
}
if _, ok := response["id"].(string); !ok {
return "", common.NewErrorf("warp set license failed: unexpected response: %s", string(body))
}
warpData["license_key"] = license
newWarpData, err := json.MarshalIndent(warpData, "", " ")
if err != nil {
return "", err
}
if err := s.SettingService.SetWarp(string(newWarpData)); err != nil {
return "", err
}
return string(newWarpData), nil
}
func (s *WarpService) ChangeWarpIP() (string, error) {
warpDataMap, err := s.loadWarpCreds()
if err != nil {
return "", err
}
privKey, pubKey, err := util.GenerateWireguardKeypair()
if err != nil {
return "", err
}
result, err := s.RegWarp(privKey, pubKey)
if err != nil {
return "", err
}
var parsed struct {
Data map[string]string `json:"data"`
Config map[string]interface{} `json:"config"`
}
if err := json.Unmarshal([]byte(result), &parsed); err != nil {
return "", err
}
xraySvc := XraySettingService{}
if err := xraySvc.UpdateWarpXraySetting(parsed.Data, parsed.Config); err != nil {
return "", err
}
if license, ok := warpDataMap["license_key"]; ok && len(license) >= 26 {
if _, licErr := s.SetWarpLicense(license); licErr != nil {
logger.Warning("ChangeWarpIP: failed to re-apply WARP license: ", licErr)
}
}
return result, nil
}
// loadWarpCreds reads the stored warp JSON and ensures access_token + device_id are set.
func (s *WarpService) loadWarpCreds() (map[string]string, error) {
warp, err := s.SettingService.GetWarp()
if err != nil {
return nil, err
}
var data map[string]string
if err := json.Unmarshal([]byte(warp), &data); err != nil {
return nil, err
}
if data["access_token"] == "" || data["device_id"] == "" {
return nil, common.NewError("warp not registered: missing access_token or device_id")
}
return data, nil
}
// doWarpRequest sends the request and returns the response body on 2xx.
// Non-2xx responses are returned as errors including the status code and body.
func (s *WarpService) doWarpRequest(req *http.Request) ([]byte, error) {
client := s.NewProxiedHTTPClient(15 * time.Second)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if msg := parseWarpError(body); msg != "" {
return nil, common.NewError(msg)
}
return nil, common.NewErrorf("warp api %s %s returned status %d: %s",
req.Method, req.URL.Path, resp.StatusCode, string(body))
}
return body, nil
}
func parseWarpError(body []byte) string {
var env struct {
Errors []struct {
Message string `json:"message"`
} `json:"errors"`
}
if err := json.Unmarshal(body, &env); err != nil {
return ""
}
if len(env.Errors) == 0 || env.Errors[0].Message == "" {
return ""
}
return env.Errors[0].Message
}