From 5d6d98d1f983f24d12a884afe671e38afc54c24d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rouzbeh=E2=80=A0?= <78313022+rqzbeh@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:30:16 +0330 Subject: [PATCH] fix(warp): preserve WARP Plus license key when changing IP (#6218) ChangeWarpIP rotated the WireGuard keypair by registering a brand-new Cloudflare device via RegWarp, which overwrites the stored warp data with the fresh registration's empty license_key. The old key was then re-applied only best-effort: any SetWarpLicense failure was swallowed with a warning log, permanently deleting the saved WARP Plus key, and even on success the response returned to the UI carried the pre-reapply snapshot (empty key). Fix: write the old license key back into the stored warp data immediately after RegWarp (before the remote upgrade attempt), so storage never loses it; keep the remote re-apply as best-effort but surface its failure as a warning field in the response; and return the final stored data so the modal shows the preserved key. The auto-update IP job shares this path and is fixed too. warpAPIBase is now a var so integration tests can point at a mock Cloudflare API. Co-authored-by: rqzbeh --- .../src/pages/xray/overrides/WarpModal.tsx | 3 + internal/web/service/integration/warp.go | 42 ++++- .../integration/warp_change_ip_test.go | 173 ++++++++++++++++++ 3 files changed, 215 insertions(+), 3 deletions(-) create mode 100644 internal/web/service/integration/warp_change_ip_test.go diff --git a/frontend/src/pages/xray/overrides/WarpModal.tsx b/frontend/src/pages/xray/overrides/WarpModal.tsx index 8af9e45af..f0fe3922e 100644 --- a/frontend/src/pages/xray/overrides/WarpModal.tsx +++ b/frontend/src/pages/xray/overrides/WarpModal.tsx @@ -238,6 +238,9 @@ export default function WarpModal({ onResetOutbound({ index: warpOutboundIndex, outbound: merged }); } } + if (parsed.warning) { + messageApi.warning(parsed.warning); + } messageApi.success(t('pages.xray.warp.changeIpSuccess', 'WARP IP changed successfully!')); } } finally { diff --git a/internal/web/service/integration/warp.go b/internal/web/service/integration/warp.go index 14598b42e..86e7d6afc 100644 --- a/internal/web/service/integration/warp.go +++ b/internal/web/service/integration/warp.go @@ -23,10 +23,13 @@ type WarpService struct { } const ( - warpAPIBase = "https://api.cloudflareclient.com/v0a4005" warpClientVer = "a-6.30-3596" ) +// warpAPIBase is the Cloudflare WARP registration API base URL. It is a var +// (not a const) so integration tests can point it at a mock server. +var warpAPIBase = "https://api.cloudflareclient.com/v0a4005" + func (s *WarpService) GetWarpData() (string, error) { return s.GetWarp() } @@ -198,6 +201,23 @@ func (s *WarpService) ChangeWarpIP() (string, error) { return "", err } + // RegWarp stores the new device's data, which for a fresh registration + // carries an empty license_key. Re-apply the old license key to the stored + // data BEFORE the remote upgrade attempt, so a failed re-apply can never + // delete the saved key. + var reapplyWarn error + if license, ok := warpDataMap["license_key"]; ok && len(license) >= 26 { + if parsed.Data == nil { + parsed.Data = make(map[string]string) + } + parsed.Data["license_key"] = license + if stored, err := json.MarshalIndent(parsed.Data, "", " "); err != nil { + return "", err + } else if err := s.SetWarp(string(stored)); err != nil { + return "", err + } + } + xraySvc := service.XraySettingService{} if err := xraySvc.UpdateWarpXraySetting(parsed.Data, parsed.Config); err != nil { return "", err @@ -205,11 +225,27 @@ func (s *WarpService) ChangeWarpIP() (string, error) { 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) + // The key is already preserved in storage above; surface the + // remote failure instead of silently downgrading to a free account. + reapplyWarn = licErr + logger.Warning("ChangeWarpIP: failed to re-apply WARP license (key preserved in storage): ", licErr) } } - return result, nil + // Return the final stored data (with the preserved license key) instead of + // RegWarp's snapshot, which always carries an empty license. + response := map[string]any{ + "data": parsed.Data, + "config": parsed.Config, + } + if reapplyWarn != nil { + response["warning"] = fmt.Sprintf("failed to re-apply WARP license: %v", reapplyWarn) + } + resultJSON, err := json.MarshalIndent(response, "", " ") + if err != nil { + return "", err + } + return string(resultJSON), nil } // loadWarpCreds reads the stored warp JSON and ensures access_token + device_id are set. diff --git a/internal/web/service/integration/warp_change_ip_test.go b/internal/web/service/integration/warp_change_ip_test.go new file mode 100644 index 000000000..ee00a02c7 --- /dev/null +++ b/internal/web/service/integration/warp_change_ip_test.go @@ -0,0 +1,173 @@ +package integration + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +// seedWarp stores warp credentials (with a Warp Plus license key) in the DB. +func seedWarp(t *testing.T, license string) { + t.Helper() + oldData := fmt.Sprintf( + `{"access_token":"old-token","device_id":"old-device","license_key":%q,"private_key":"old-priv"}`, + license, + ) + if err := database.GetDB().Create(&model.Setting{Key: "warp", Value: oldData}).Error; err != nil { + t.Fatalf("seed warp: %v", err) + } +} + +// mockWarpAPI emulates the Cloudflare WARP registration API. When reapplyFails +// is true, the PUT /reg/{id}/account endpoint returns 500 (license rejected). +func mockWarpAPI(t *testing.T, reapplyFails bool) (*httptest.Server, *atomic.Int32, *atomic.Int32) { + t.Helper() + regCalls := &atomic.Int32{} + licCalls := &atomic.Int32{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/reg": + regCalls.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"new-device","token":"new-token","account":{"license":""},"config":{"client_id":"YWJj"}}`)) + case r.Method == http.MethodPut && r.URL.Path == "/reg/new-device/account": + licCalls.Add(1) + if reapplyFails { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"license already in use"}`)) + return + } + var body map[string]string + _ = json.NewDecoder(r.Body).Decode(&body) + if body["license"] != "WARPPLLUS-KEY-0123456789abcdefgh" { + t.Errorf("re-apply license: got %q, want the saved Warp Plus key", body["license"]) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"new-device"}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(srv.Close) + return srv, regCalls, licCalls +} + +func withWarpAPIBase(t *testing.T, base string) { + t.Helper() + orig := warpAPIBase + warpAPIBase = base + t.Cleanup(func() { warpAPIBase = orig }) +} + +func TestChangeWarpIPPreservesLicenseKey(t *testing.T) { + if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil { + t.Fatalf("InitDB: %v", err) + } + t.Cleanup(func() { _ = database.CloseDB() }) + + const license = "WARPPLLUS-KEY-0123456789abcdefgh" // 32 chars, >= 26 gate + seedWarp(t, license) + srv, regCalls, licCalls := mockWarpAPI(t, false) + withWarpAPIBase(t, srv.URL) + + s := &WarpService{} + resp, err := s.ChangeWarpIP() + if err != nil { + t.Fatalf("ChangeWarpIP: %v", err) + } + + // Storage must keep the license key and the new device id. + stored, err := s.GetWarp() + if err != nil { + t.Fatalf("GetWarp: %v", err) + } + var storedData map[string]string + if err := json.Unmarshal([]byte(stored), &storedData); err != nil { + t.Fatalf("unmarshal stored warp: %v", err) + } + if storedData["license_key"] != license { + t.Errorf("stored license_key = %q, want %q (key must survive changeIp)", storedData["license_key"], license) + } + if storedData["device_id"] != "new-device" { + t.Errorf("stored device_id = %q, want %q (IP must still rotate)", storedData["device_id"], "new-device") + } + + // The response must carry the license key so the UI shows it. + var parsed struct { + Data map[string]string `json:"data"` + } + if err := json.Unmarshal([]byte(resp), &parsed); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if parsed.Data["license_key"] != license { + t.Errorf("response license_key = %q, want %q", parsed.Data["license_key"], license) + } + if strings.Contains(resp, "warning") { + t.Errorf("response unexpectedly contains a warning: %s", resp) + } + + if regCalls.Load() != 1 { + t.Errorf("reg calls = %d, want 1", regCalls.Load()) + } + if licCalls.Load() != 1 { + t.Errorf("license re-apply calls = %d, want 1", licCalls.Load()) + } +} + +func TestChangeWarpIPKeepsLicenseWhenReapplyFails(t *testing.T) { + if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil { + t.Fatalf("InitDB: %v", err) + } + t.Cleanup(func() { _ = database.CloseDB() }) + + const license = "WARPPLLUS-KEY-0123456789abcdefgh" + seedWarp(t, license) + srv, _, licCalls := mockWarpAPI(t, true) + withWarpAPIBase(t, srv.URL) + + s := &WarpService{} + resp, err := s.ChangeWarpIP() + if err != nil { + t.Fatalf("ChangeWarpIP: %v", err) + } + + // Even when Cloudflare rejects the re-apply, the saved key must stay. + stored, err := s.GetWarp() + if err != nil { + t.Fatalf("GetWarp: %v", err) + } + var storedData map[string]string + if err := json.Unmarshal([]byte(stored), &storedData); err != nil { + t.Fatalf("unmarshal stored warp: %v", err) + } + if storedData["license_key"] != license { + t.Errorf("stored license_key = %q, want %q (re-apply failure must not delete the key)", storedData["license_key"], license) + } + + // The response must warn the user instead of silently succeeding. + var parsed struct { + Data map[string]string `json:"data"` + Warning string `json:"warning"` + } + if err := json.Unmarshal([]byte(resp), &parsed); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if parsed.Warning == "" { + t.Error("response missing warning about failed license re-apply") + } + if parsed.Data["license_key"] != license { + t.Errorf("response license_key = %q, want %q", parsed.Data["license_key"], license) + } + if licCalls.Load() != 1 { + t.Errorf("license re-apply calls = %d, want 1", licCalls.Load()) + } +}