fix(warp): harden API client and frontend, bump to v0a4005

Backend:
- check HTTP status on every Cloudflare API call so error bodies don't
  get parsed as success
- replace unchecked type assertions with comma-ok form (no more panics
  when Cloudflare returns an error response)
- return real errors when license/id/token fields are missing instead
  of swallowing the failure
- guard SetWarpLicense against an empty errors array
- 15s timeout on the shared http.Client
- build all request bodies and persisted state with json.Marshal
- bump API path to v0a4005 and CF-Client-Version to a-6.30-3596 to
  match the current Cloudflare WARP client

Frontend (warp_modal.html):
- remove stray </a-form-item> closing tag
- declare config/peer with const and null-check before dereferencing
- guard addOutbound/resetOutbound against missing warpOutbound
- rename getResolved -> getReserved (the array it builds is "reserved")

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MHSanaei
2026-05-08 09:29:42 +02:00
parent f2bc4938b7
commit d8198f543b
2 changed files with 143 additions and 105 deletions

View File

@@ -88,7 +88,6 @@
<a-button @click="addOutbound" :loading="warpModal.confirmLoading" <a-button @click="addOutbound" :loading="warpModal.confirmLoading"
type="primary">{{ i18n "pages.xray.outbound.addOutbound" }}</a-button> type="primary">{{ i18n "pages.xray.outbound.addOutbound" }}</a-button>
</template> </template>
</a-form-item>
</a-form> </a-form>
</template> </template>
</template> </template>
@@ -131,9 +130,11 @@
}, },
methods: { methods: {
collectConfig() { collectConfig() {
config = warpModal.warpConfig.config; const config = warpModal.warpConfig && warpModal.warpConfig.config;
peer = config.peers[0]; if (!config || !config.peers || !config.peers.length) {
if (config) { return;
}
const peer = config.peers[0];
warpModal.warpOutbound = Outbound.fromJson({ warpModal.warpOutbound = Outbound.fromJson({
tag: 'warp', tag: 'warp',
protocol: Protocols.Wireguard, protocol: Protocols.Wireguard,
@@ -141,7 +142,7 @@
mtu: 1420, mtu: 1420,
secretKey: warpModal.warpData.private_key, secretKey: warpModal.warpData.private_key,
address: this.getAddresses(config.interface.addresses), address: this.getAddresses(config.interface.addresses),
reserved: this.getResolved(config.client_id), reserved: this.getReserved(config.client_id),
domainStrategy: 'ForceIP', domainStrategy: 'ForceIP',
peers: [{ peers: [{
publicKey: peer.public_key, publicKey: peer.public_key,
@@ -150,7 +151,6 @@
noKernelTun: false, noKernelTun: false,
} }
}); });
}
}, },
getAddresses(addrs) { getAddresses(addrs) {
let addresses = []; let addresses = [];
@@ -158,7 +158,7 @@
if (addrs.v6) addresses.push(addrs.v6 + "/128"); if (addrs.v6) addresses.push(addrs.v6 + "/128");
return addresses; return addresses;
}, },
getResolved(client_id) { getReserved(client_id) {
let reserved = []; let reserved = [];
let decoded = atob(client_id); let decoded = atob(client_id);
let hexString = ''; let hexString = '';
@@ -218,11 +218,13 @@
} }
}, },
addOutbound() { addOutbound() {
if (!warpModal.warpOutbound) return;
app.templateSettings.outbounds.push(warpModal.warpOutbound.toJson()); app.templateSettings.outbounds.push(warpModal.warpOutbound.toJson());
app.outboundSettings = JSON.stringify(app.templateSettings.outbounds); app.outboundSettings = JSON.stringify(app.templateSettings.outbounds);
warpModal.close(); warpModal.close();
}, },
resetOutbound() { resetOutbound() {
if (!warpModal.warpOutbound) return;
app.templateSettings.outbounds[this.warpOutboundIndex] = warpModal.warpOutbound.toJson(); app.templateSettings.outbounds[this.warpOutboundIndex] = warpModal.warpOutbound.toJson();
app.outboundSettings = JSON.stringify(app.templateSettings.outbounds); app.outboundSettings = JSON.stringify(app.templateSettings.outbounds);
warpModal.close(); warpModal.close();

View File

@@ -4,11 +4,11 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http" "net/http"
"os" "os"
"time" "time"
"github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/util/common" "github.com/mhsanaei/3x-ui/v2/util/common"
) )
@@ -18,156 +18,192 @@ type WarpService struct {
SettingService SettingService
} }
const (
warpAPIBase = "https://api.cloudflareclient.com/v0a4005"
warpClientVer = "a-6.30-3596"
)
var warpHTTPClient = &http.Client{Timeout: 15 * time.Second}
func (s *WarpService) GetWarpData() (string, error) { func (s *WarpService) GetWarpData() (string, error) {
warp, err := s.SettingService.GetWarp() return s.SettingService.GetWarp()
if err != nil {
return "", err
}
return warp, nil
} }
func (s *WarpService) DelWarpData() error { func (s *WarpService) DelWarpData() error {
err := s.SettingService.SetWarp("") return s.SettingService.SetWarp("")
if err != nil {
return err
}
return nil
} }
func (s *WarpService) GetWarpConfig() (string, error) { func (s *WarpService) GetWarpConfig() (string, error) {
var warpData map[string]string warpData, err := s.loadWarpCreds()
warp, err := s.SettingService.GetWarp()
if err != nil {
return "", err
}
err = json.Unmarshal([]byte(warp), &warpData)
if err != nil { if err != nil {
return "", err return "", err
} }
url := fmt.Sprintf("https://api.cloudflareclient.com/v0a2158/reg/%s", warpData["device_id"]) url := fmt.Sprintf("%s/reg/%s", warpAPIBase, warpData["device_id"])
req, err := http.NewRequest(http.MethodGet, url, nil)
req, err := http.NewRequest("GET", url, nil)
if err != nil { if err != nil {
return "", err return "", err
} }
req.Header.Set("Authorization", "Bearer "+warpData["access_token"]) req.Header.Set("Authorization", "Bearer "+warpData["access_token"])
client := &http.Client{} body, err := doWarpRequest(req)
resp, err := client.Do(req)
if err != nil { if err != nil {
return "", err return "", err
} }
defer resp.Body.Close() return string(body), nil
buffer := &bytes.Buffer{}
_, err = buffer.ReadFrom(resp.Body)
if err != nil {
return "", err
}
return buffer.String(), nil
} }
func (s *WarpService) RegWarp(secretKey string, publicKey string) (string, error) { func (s *WarpService) RegWarp(secretKey string, publicKey string) (string, error) {
tos := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
hostName, _ := os.Hostname() hostName, _ := os.Hostname()
data := fmt.Sprintf(`{"key":"%s","tos":"%s","type": "PC","model": "x-ui", "name": "%s"}`, publicKey, tos, hostName) reqBody, err := json.Marshal(map[string]any{
"key": publicKey,
url := "https://api.cloudflareclient.com/v0a2158/reg" "tos": time.Now().UTC().Format("2006-01-02T15:04:05.000Z"),
"type": "PC",
req, err := http.NewRequest("POST", url, bytes.NewBuffer([]byte(data))) "model": "x-ui",
"name": hostName,
})
if err != nil { if err != nil {
return "", err return "", err
} }
req.Header.Add("CF-Client-Version", "a-7.21-0721") req, err := http.NewRequest(http.MethodPost, warpAPIBase+"/reg", bytes.NewReader(reqBody))
req.Header.Add("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil { if err != nil {
return "", err return "", err
} }
defer resp.Body.Close() req.Header.Set("CF-Client-Version", warpClientVer)
buffer := &bytes.Buffer{} req.Header.Set("Content-Type", "application/json")
_, err = buffer.ReadFrom(resp.Body)
body, err := doWarpRequest(req)
if err != nil { if err != nil {
return "", err return "", err
} }
var rspData map[string]any var rsp map[string]any
err = json.Unmarshal(buffer.Bytes(), &rspData) if err := json.Unmarshal(body, &rsp); err != nil {
if err != nil {
return "", err return "", err
} }
deviceId := rspData["id"].(string) deviceID, ok := rsp["id"].(string)
token := rspData["token"].(string)
license, ok := rspData["account"].(map[string]any)["license"].(string)
if !ok { if !ok {
logger.Debug("Error accessing license value.") 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,
}
warpJSON, err := json.MarshalIndent(warpData, "", " ")
if err != nil {
return "", err
}
if err := s.SettingService.SetWarp(string(warpJSON)); err != nil {
return "", err return "", err
} }
warpData := fmt.Sprintf("{\n \"access_token\": \"%s\",\n \"device_id\": \"%s\",", token, deviceId) result, err := json.MarshalIndent(map[string]any{
warpData += fmt.Sprintf("\n \"license_key\": \"%s\",\n \"private_key\": \"%s\"\n}", license, secretKey) "data": warpData,
"config": json.RawMessage(body),
s.SettingService.SetWarp(warpData) }, "", " ")
if err != nil {
result := fmt.Sprintf("{\n \"data\": %s,\n \"config\": %s\n}", warpData, buffer.String()) return "", err
}
return result, nil return string(result), nil
} }
func (s *WarpService) SetWarpLicense(license string) (string, error) { func (s *WarpService) SetWarpLicense(license string) (string, error) {
var warpData map[string]string warpData, err := s.loadWarpCreds()
warp, err := s.SettingService.GetWarp()
if err != nil {
return "", err
}
err = json.Unmarshal([]byte(warp), &warpData)
if err != nil { if err != nil {
return "", err return "", err
} }
url := fmt.Sprintf("https://api.cloudflareclient.com/v0a2158/reg/%s/account", warpData["device_id"]) url := fmt.Sprintf("%s/reg/%s/account", warpAPIBase, warpData["device_id"])
data := fmt.Sprintf(`{"license": "%s"}`, license) reqBody, err := json.Marshal(map[string]string{"license": license})
if err != nil {
return "", err
}
req, err := http.NewRequest("PUT", url, bytes.NewBuffer([]byte(data))) req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(reqBody))
if err != nil { if err != nil {
return "", err return "", err
} }
req.Header.Set("Authorization", "Bearer "+warpData["access_token"]) req.Header.Set("Authorization", "Bearer "+warpData["access_token"])
req.Header.Set("Content-Type", "application/json")
client := &http.Client{} body, err := doWarpRequest(req)
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
buffer := &bytes.Buffer{}
_, err = buffer.ReadFrom(resp.Body)
if err != nil { if err != nil {
return "", err return "", err
} }
var response map[string]any var response map[string]any
err = json.Unmarshal(buffer.Bytes(), &response) if err := json.Unmarshal(body, &response); err != nil {
if err != nil {
return "", err return "", err
} }
if response["success"] == false { if success, _ := response["success"].(bool); !success {
errorArr, _ := response["errors"].([]any) if errorArr, ok := response["errors"].([]any); ok && len(errorArr) > 0 {
errorObj := errorArr[0].(map[string]any) if errorObj, ok := errorArr[0].(map[string]any); ok {
return "", common.NewError(errorObj["code"], errorObj["message"]) return "", common.NewError(errorObj["code"], errorObj["message"])
} }
}
return "", common.NewError("warp set license failed: unknown error")
}
warpData["license_key"] = license warpData["license_key"] = license
newWarpData, err := json.MarshalIndent(warpData, "", " ") newWarpData, err := json.MarshalIndent(warpData, "", " ")
if err != nil { if err != nil {
return "", err return "", err
} }
s.SettingService.SetWarp(string(newWarpData)) if err := s.SettingService.SetWarp(string(newWarpData)); err != nil {
return "", err
}
return string(newWarpData), nil return string(newWarpData), 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 doWarpRequest(req *http.Request) ([]byte, error) {
resp, err := warpHTTPClient.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 {
return nil, common.NewErrorf("warp api %s %s returned status %d: %s",
req.Method, req.URL.Path, resp.StatusCode, string(body))
}
return body, nil
}