From 22ff07b24ba17e8d8f70297bced5d1325da75e8d Mon Sep 17 00:00:00 2001 From: H-TTTTT <36735327+H-TTTTT@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:50:00 +0800 Subject: [PATCH] fix(warp): preserve outbound customization when rotating IP (#6052) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit changeIp() rebuilt the warp outbound from a fixed default template via collectConfig and replaced the whole in-memory object through onResetOutbound, so only secretKey/address/reserved and the first peer's publicKey/endpoint survived — a user's custom mtu, domainStrategy, noKernelTun, the first peer's keepAlive / allowedIPs / preSharedKey, and any extra peers were silently dropped from the editor. Because the page keeps rendering that in-memory copy and a later page Save persists it to /panel/api/xray/update, the loss could become permanent, even though the backend ChangeWarpIP already patches only the rotated fields and leaves everything else intact in the stored template. Mirror the server-side UpdateWarpXraySetting: merge only the rotated fields (secretKey, address, reserved, first peer publicKey/endpoint) into the existing outbound, preserving the rest. register() (first-time creation, nothing to preserve) still uses collectConfig's full build. Extracted as a pure module-level mergeWarpRotation so it is unit-testable without rendering the modal. Fixes #6019 --- .../src/pages/xray/overrides/WarpModal.tsx | 49 +++++++-- frontend/src/test/warp-change-ip.test.ts | 99 +++++++++++++++++++ 2 files changed, 142 insertions(+), 6 deletions(-) create mode 100644 frontend/src/test/warp-change-ip.test.ts diff --git a/frontend/src/pages/xray/overrides/WarpModal.tsx b/frontend/src/pages/xray/overrides/WarpModal.tsx index 1698a0795..8af9e45af 100644 --- a/frontend/src/pages/xray/overrides/WarpModal.tsx +++ b/frontend/src/pages/xray/overrides/WarpModal.tsx @@ -75,6 +75,40 @@ function reservedFor(clientId?: string): number[] { return out; } +export function mergeWarpRotation( + existing: Record | undefined, + data: WarpData | null, + config: WarpConfig | null, +): Record | null { + const cfg = config?.config; + const peer = cfg?.peers?.[0]; + if (!cfg || !peer) return null; + const base: Record = + existing && typeof existing === 'object' ? { ...existing } : { tag: 'warp', protocol: 'wireguard' }; + const prevSettings = + base.settings && typeof base.settings === 'object' + ? { ...(base.settings as Record) } + : {}; + const prevPeers = Array.isArray(prevSettings.peers) + ? [...(prevSettings.peers as Record[])] + : []; + const prevFirstPeer = + prevPeers[0] && typeof prevPeers[0] === 'object' + ? { ...(prevPeers[0] as Record) } + : {}; + prevFirstPeer.publicKey = peer.public_key; + prevFirstPeer.endpoint = peer.endpoint?.host; + prevPeers[0] = prevFirstPeer; + prevSettings.secretKey = data?.private_key; + prevSettings.address = addressesFor(cfg.interface?.addresses || {}); + prevSettings.reserved = reservedFor(cfg.client_id ?? data?.client_id); + prevSettings.peers = prevPeers; + base.settings = prevSettings; + base.tag = 'warp'; + base.protocol = 'wireguard'; + return base; +} + export default function WarpModal({ open, templateSettings, @@ -194,12 +228,15 @@ export default function WarpModal({ const parsed = JSON.parse(msg.obj); setWarpData(parsed.data); setWarpConfig(parsed.config); - const built = collectConfig(parsed.data, parsed.config); - // The backend already persisted the new keys into the saved Xray - // template; keep the in-memory editor in sync so a later template - // save doesn't revert them to the old keys. - if (built && warpOutboundIndex >= 0) { - onResetOutbound({ index: warpOutboundIndex, outbound: built }); + collectConfig(parsed.data, parsed.config); + if (warpOutboundIndex >= 0) { + const existing = templateSettings?.outbounds?.[warpOutboundIndex] as + | Record + | undefined; + const merged = mergeWarpRotation(existing, parsed.data, parsed.config); + if (merged) { + onResetOutbound({ index: warpOutboundIndex, outbound: merged }); + } } messageApi.success(t('pages.xray.warp.changeIpSuccess', 'WARP IP changed successfully!')); } diff --git a/frontend/src/test/warp-change-ip.test.ts b/frontend/src/test/warp-change-ip.test.ts new file mode 100644 index 000000000..3389adf8c --- /dev/null +++ b/frontend/src/test/warp-change-ip.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; + +import { mergeWarpRotation } from '@/pages/xray/overrides/WarpModal'; + +const clientId = btoa(String.fromCharCode(1, 2, 3)); + +function rotatedConfig(overrides: { public_key?: string; host?: string; v4?: string; v6?: string } = {}) { + return { + config: { + client_id: clientId, + interface: { addresses: { v4: overrides.v4 ?? '172.16.0.2', v6: overrides.v6 ?? '2606:4700::2' } }, + peers: [{ public_key: overrides.public_key ?? 'newPub', endpoint: { host: overrides.host ?? 'engage.cloudflareclient.com:2408' } }], + }, + }; +} + +describe('mergeWarpRotation', () => { + it('patches only rotated fields and preserves user customizations', () => { + const existing = { + tag: 'warp', + protocol: 'wireguard', + settings: { + mtu: 1280, + secretKey: 'oldSecret', + address: ['172.16.0.2/32'], + reserved: [9, 9, 9], + domainStrategy: 'ForceIPv4', + noKernelTun: false, + peers: [ + { + publicKey: 'oldPub', + endpoint: 'engage.cloudflareclient.com:2408', + keepAlive: 25, + allowedIPs: ['10.0.0.0/24'], + preSharedKey: 'psk', + }, + { publicKey: 'extraPeer', endpoint: 'extra.test:51820' }, + ], + }, + }; + + const merged = mergeWarpRotation( + existing, + { private_key: 'newSecret' }, + rotatedConfig({ public_key: 'newPub', host: 'engage.cloudflareclient.com:2408', v4: '172.16.0.9' }), + ); + + expect(merged).not.toBeNull(); + const settings = (merged as { settings: Record }).settings; + expect(settings.secretKey).toBe('newSecret'); + expect(settings.address).toEqual(['172.16.0.9/32', '2606:4700::2/128']); + expect(settings.reserved).toEqual([1, 2, 3]); + const peers = settings.peers as Array>; + expect(peers[0].publicKey).toBe('newPub'); + expect(peers[0].endpoint).toBe('engage.cloudflareclient.com:2408'); + expect(peers[0].keepAlive).toBe(25); + expect(peers[0].allowedIPs).toEqual(['10.0.0.0/24']); + expect(peers[0].preSharedKey).toBe('psk'); + expect(peers[1]).toEqual({ publicKey: 'extraPeer', endpoint: 'extra.test:51820' }); + expect(settings.mtu).toBe(1280); + expect(settings.domainStrategy).toBe('ForceIPv4'); + expect(settings.noKernelTun).toBe(false); + }); + + it('does not mutate the existing outbound object', () => { + const existing = { + tag: 'warp', + protocol: 'wireguard', + settings: { + secretKey: 'oldSecret', + address: ['172.16.0.2/32'], + reserved: [9, 9, 9], + peers: [{ publicKey: 'oldPub', endpoint: 'old:1', keepAlive: 25 }], + }, + }; + const snapshot = JSON.parse(JSON.stringify(existing)); + + mergeWarpRotation(existing, { private_key: 'newSecret' }, rotatedConfig()); + + expect(existing).toEqual(snapshot); + }); + + it('returns null when the rotation response has no peer', () => { + expect(mergeWarpRotation(undefined, null, { config: { peers: [] } })).toBeNull(); + expect(mergeWarpRotation(undefined, null, null)).toBeNull(); + }); + + it('seeds a default warp outbound when none existed yet', () => { + const merged = mergeWarpRotation(undefined, { private_key: 'newSecret' }, rotatedConfig()); + expect(merged).not.toBeNull(); + expect((merged as Record).tag).toBe('warp'); + expect((merged as Record).protocol).toBe('wireguard'); + const settings = (merged as { settings: Record }).settings; + expect(settings.secretKey).toBe('newSecret'); + expect(settings.peers).toEqual([ + { publicKey: 'newPub', endpoint: 'engage.cloudflareclient.com:2408' }, + ]); + }); +});