Преглед изворни кода

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 <[email protected]>
Rouzbeh† пре 13 часа
родитељ
комит
5d6d98d1f9

+ 3 - 0
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 {

+ 39 - 3
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.

+ 173 - 0
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())
+	}
+}