Bladeren bron

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 <[email protected]>
Co-authored-by: Sanaei <[email protected]>
Rouzbeh† 12 uur geleden
bovenliggende
commit
d9ccf157c3

+ 12 - 0
frontend/public/openapi.json

@@ -325,6 +325,11 @@
             "description": "Two-factor authentication token",
             "type": "string"
           },
+          "warpUpdateInterval": {
+            "description": "WARP",
+            "minimum": 0,
+            "type": "integer"
+          },
           "webBasePath": {
             "description": "Base path for web panel URLs",
             "type": "string"
@@ -427,6 +432,7 @@
           "trustedProxyCIDRs",
           "twoFactorEnable",
           "twoFactorToken",
+          "warpUpdateInterval",
           "webBasePath",
           "webCertFile",
           "webDomain",
@@ -753,6 +759,11 @@
             "description": "Two-factor authentication token",
             "type": "string"
           },
+          "warpUpdateInterval": {
+            "description": "WARP",
+            "minimum": 0,
+            "type": "integer"
+          },
           "webBasePath": {
             "description": "Base path for web panel URLs",
             "type": "string"
@@ -861,6 +872,7 @@
           "trustedProxyCIDRs",
           "twoFactorEnable",
           "twoFactorToken",
+          "warpUpdateInterval",
           "webBasePath",
           "webCertFile",
           "webDomain",

+ 2 - 0
frontend/src/generated/examples.ts

@@ -75,6 +75,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "trustedProxyCIDRs": "",
     "twoFactorEnable": false,
     "twoFactorToken": "",
+    "warpUpdateInterval": 0,
     "webBasePath": "",
     "webCertFile": "",
     "webDomain": "",
@@ -163,6 +164,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "trustedProxyCIDRs": "",
     "twoFactorEnable": false,
     "twoFactorToken": "",
+    "warpUpdateInterval": 0,
     "webBasePath": "",
     "webCertFile": "",
     "webDomain": "",

+ 12 - 0
frontend/src/generated/schemas.ts

@@ -299,6 +299,11 @@ export const SCHEMAS: Record<string, unknown> = {
         "description": "Two-factor authentication token",
         "type": "string"
       },
+      "warpUpdateInterval": {
+        "description": "WARP",
+        "minimum": 0,
+        "type": "integer"
+      },
       "webBasePath": {
         "description": "Base path for web panel URLs",
         "type": "string"
@@ -401,6 +406,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "trustedProxyCIDRs",
       "twoFactorEnable",
       "twoFactorToken",
+      "warpUpdateInterval",
       "webBasePath",
       "webCertFile",
       "webDomain",
@@ -727,6 +733,11 @@ export const SCHEMAS: Record<string, unknown> = {
         "description": "Two-factor authentication token",
         "type": "string"
       },
+      "warpUpdateInterval": {
+        "description": "WARP",
+        "minimum": 0,
+        "type": "integer"
+      },
       "webBasePath": {
         "description": "Base path for web panel URLs",
         "type": "string"
@@ -835,6 +846,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "trustedProxyCIDRs",
       "twoFactorEnable",
       "twoFactorToken",
+      "warpUpdateInterval",
       "webBasePath",
       "webCertFile",
       "webDomain",

+ 2 - 0
frontend/src/generated/types.ts

@@ -80,6 +80,7 @@ export interface AllSetting {
   trustedProxyCIDRs: string;
   twoFactorEnable: boolean;
   twoFactorToken: string;
+  warpUpdateInterval: number;
   webBasePath: string;
   webCertFile: string;
   webDomain: string;
@@ -169,6 +170,7 @@ export interface AllSettingView {
   trustedProxyCIDRs: string;
   twoFactorEnable: boolean;
   twoFactorToken: string;
+  warpUpdateInterval: number;
   webBasePath: string;
   webCertFile: string;
   webDomain: string;

+ 2 - 0
frontend/src/generated/zod.ts

@@ -90,6 +90,7 @@ export const AllSettingSchema = z.object({
   trustedProxyCIDRs: z.string(),
   twoFactorEnable: z.boolean(),
   twoFactorToken: z.string(),
+  warpUpdateInterval: z.number().int().min(0),
   webBasePath: z.string(),
   webCertFile: z.string(),
   webDomain: z.string(),
@@ -180,6 +181,7 @@ export const AllSettingViewSchema = z.object({
   trustedProxyCIDRs: z.string(),
   twoFactorEnable: z.boolean(),
   twoFactorToken: z.string(),
+  warpUpdateInterval: z.number().int().min(0),
   webBasePath: z.string(),
   webCertFile: z.string(),
   webDomain: z.string(),

+ 89 - 21
frontend/src/pages/xray/overrides/WarpModal.tsx

@@ -80,6 +80,7 @@ export default function WarpModal({
   const [warpData, setWarpData] = useState<WarpData | null>(null);
   const [warpConfig, setWarpConfig] = useState<WarpConfig | null>(null);
   const [warpPlus, setWarpPlus] = useState('');
+  const [updateInterval, setUpdateInterval] = useState<number>(0);
   const [licenseError, setLicenseError] = useState('');
   const [stagedOutbound, setStagedOutbound] = useState<Record<string, unknown> | null>(null);
 
@@ -89,24 +90,29 @@ export default function WarpModal({
     return list.findIndex((o) => o?.tag === 'warp');
   }, [templateSettings?.outbounds]);
 
-  const collectConfig = useCallback((data: WarpData | null, config: WarpConfig | null) => {
-    const cfg = config?.config;
-    if (!cfg?.peers?.length) return;
-    const peer = cfg.peers[0];
-    setStagedOutbound({
-      tag: 'warp',
-      protocol: 'wireguard',
-      settings: {
-        mtu: 1420,
-        secretKey: data?.private_key,
-        address: addressesFor(cfg.interface?.addresses || {}),
-        reserved: reservedFor(cfg.client_id ?? data?.client_id),
-        domainStrategy: 'ForceIP',
-        peers: [{ publicKey: peer.public_key, endpoint: peer.endpoint?.host }],
-        noKernelTun: false,
-      },
-    });
-  }, []);
+  const collectConfig = useCallback(
+    (data: WarpData | null, config: WarpConfig | null): Record<string, unknown> | null => {
+      const cfg = config?.config;
+      if (!cfg?.peers?.length) return null;
+      const peer = cfg.peers[0];
+      const outbound: Record<string, unknown> = {
+        tag: 'warp',
+        protocol: 'wireguard',
+        settings: {
+          mtu: 1420,
+          secretKey: data?.private_key,
+          address: addressesFor(cfg.interface?.addresses || {}),
+          reserved: reservedFor(cfg.client_id ?? data?.client_id),
+          domainStrategy: 'ForceIP',
+          peers: [{ publicKey: peer.public_key, endpoint: peer.endpoint?.host }],
+          noKernelTun: false,
+        },
+      };
+      setStagedOutbound(outbound);
+      return outbound;
+    },
+    [],
+  );
 
   const fetchData = useCallback(async () => {
     setLoading(true);
@@ -116,6 +122,10 @@ export default function WarpModal({
         const raw = msg.obj;
         setWarpData(raw && raw.length > 0 ? JSON.parse(raw) : null);
       }
+      const settingMsg = await HttpUtil.post<Record<string, unknown>>('/panel/api/setting/all');
+      if (settingMsg?.success && settingMsg.obj) {
+        setUpdateInterval(Number(settingMsg.obj.warpUpdateInterval) || 0);
+      }
     } finally {
       setLoading(false);
     }
@@ -159,6 +169,40 @@ export default function WarpModal({
     }
   }
 
+  async function changeIp() {
+    setLoading(true);
+    try {
+      const msg = await HttpUtil.post<string>('/panel/api/xray/warp/changeIp');
+      if (msg?.success && msg.obj) {
+        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 });
+        }
+        messageApi.success(t('pages.xray.warp.changeIpSuccess', 'WARP IP changed successfully!'));
+      }
+    } finally {
+      setLoading(false);
+    }
+  }
+
+  async function saveInterval() {
+    setLoading(true);
+    try {
+      const msg = await HttpUtil.post('/panel/api/xray/warp/interval', { interval: updateInterval });
+      if (msg?.success) {
+        messageApi.success(t('pages.setting.toasts.saveSuccess', 'Settings saved successfully'));
+      }
+    } finally {
+      setLoading(false);
+    }
+  }
+
   async function updateLicense() {
     if (warpPlus.length < 26) return;
     setLoading(true);
@@ -281,13 +325,37 @@ export default function WarpModal({
                   </Form>
                 ),
               },
+              {
+                key: '2',
+                label: t('pages.xray.warp.autoUpdateIp', 'Auto Update IP Address'),
+                children: (
+                  <Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 12 } }}>
+                    <Form.Item label={t('pages.xray.warp.intervalDays', 'Interval (Days)')} extra={t('pages.xray.warp.intervalDesc', '0 to disable. Changes IP address automatically.')}>
+                      <Input
+                        type="number"
+                        min={0}
+                        value={updateInterval}
+                        onChange={(e) => setUpdateInterval(Number(e.target.value))}
+                      />
+                      <Button className="mt-8" type="primary" loading={loading} onClick={saveInterval}>
+                        {t('save', 'Save')}
+                      </Button>
+                    </Form.Item>
+                  </Form>
+                ),
+              },
             ]}
           />
 
           <Divider className="zero-margin">{t('pages.xray.warp.accountInfo')}</Divider>
-          <Button className="my-8" loading={loading} type="primary" icon={<SyncOutlined />} onClick={getConfig}>
-            {t('refresh')}
-          </Button>
+          <div className="my-8">
+            <Button loading={loading} type="primary" icon={<SyncOutlined />} onClick={getConfig}>
+              {t('refresh')}
+            </Button>
+            <Button loading={loading} type="primary" className="ml-8" icon={<SyncOutlined />} onClick={changeIp}>
+              {t('pages.xray.warp.changeIp', 'Change IP')}
+            </Button>
+          </div>
 
           {hasConfig && (
             <>

+ 24 - 0
util/wireguard.go

@@ -0,0 +1,24 @@
+package util
+
+import (
+	"crypto/rand"
+	"encoding/base64"
+
+	"golang.org/x/crypto/curve25519"
+)
+
+// GenerateWireguardKeypair generates a base64 encoded private and public key pair for Wireguard.
+func GenerateWireguardKeypair() (privateKey string, publicKey string, err error) {
+	var priv [32]byte
+	if _, err := rand.Read(priv[:]); err != nil {
+		return "", "", err
+	}
+	priv[0] &= 248
+	priv[31] &= 127
+	priv[31] |= 64
+
+	var pub [32]byte
+	curve25519.ScalarBaseMult(&pub, &priv)
+
+	return base64.StdEncoding.EncodeToString(priv[:]), base64.StdEncoding.EncodeToString(pub[:]), nil
+}

+ 26 - 7
web/controller/xray_setting.go

@@ -3,6 +3,8 @@ package controller
 import (
 	"encoding/json"
 	"fmt"
+	"strconv"
+	"time"
 
 	"github.com/mhsanaei/3x-ui/v3/util/common"
 	"github.com/mhsanaei/3x-ui/v3/web/service"
@@ -12,13 +14,13 @@ import (
 
 // XraySettingController handles Xray configuration and settings operations.
 type XraySettingController struct {
-	XraySettingService service.XraySettingService
-	SettingService     service.SettingService
-	InboundService     service.InboundService
-	OutboundService          service.OutboundService
-	XrayService              service.XrayService
-	WarpService              service.WarpService
-	NordService              service.NordService
+	XraySettingService          service.XraySettingService
+	SettingService              service.SettingService
+	InboundService              service.InboundService
+	OutboundService             service.OutboundService
+	XrayService                 service.XrayService
+	WarpService                 service.WarpService
+	NordService                 service.NordService
 	OutboundSubscriptionService service.OutboundSubscriptionService
 }
 
@@ -165,9 +167,26 @@ func (a *XraySettingController) warp(c *gin.Context) {
 		skey := c.PostForm("privateKey")
 		pkey := c.PostForm("publicKey")
 		resp, err = a.WarpService.RegWarp(skey, pkey)
+	case "changeIp":
+		resp, err = a.WarpService.ChangeWarpIP()
+		if err == nil {
+			a.XrayService.SetToNeedRestart()
+			// Restart the auto-update clock so a scheduled rotation
+			// doesn't fire right after this manual one.
+			_ = a.SettingService.SetWarpLastUpdate(time.Now().Unix())
+		}
 	case "license":
 		license := c.PostForm("license")
 		resp, err = a.WarpService.SetWarpLicense(license)
+	case "interval":
+		interval, convErr := strconv.Atoi(c.PostForm("interval"))
+		if convErr != nil || interval < 0 {
+			err = common.NewError("invalid warp update interval")
+		} else if err = a.SettingService.SetWarpUpdateInterval(interval); err == nil && interval > 0 {
+			// Count the interval from now rather than from epoch 0,
+			// otherwise the job would rotate on its next tick.
+			_ = a.SettingService.SetWarpLastUpdate(time.Now().Unix())
+		}
 	}
 
 	jsonObj(c, resp, err)

+ 3 - 0
web/entity/entity.go

@@ -113,6 +113,9 @@ type AllSetting struct {
 	LdapDefaultExpiryDays int    `json:"ldapDefaultExpiryDays" form:"ldapDefaultExpiryDays" validate:"gte=0"`
 	LdapDefaultLimitIP    int    `json:"ldapDefaultLimitIP" form:"ldapDefaultLimitIP" validate:"gte=0"`
 	// JSON subscription routing rules
+
+	// WARP
+	WarpUpdateInterval int `json:"warpUpdateInterval" form:"warpUpdateInterval" validate:"gte=0"`
 }
 
 // AllSettingView is the browser-safe settings read model. Secret values

+ 52 - 0
web/job/warp_ip_job.go

@@ -0,0 +1,52 @@
+package job
+
+import (
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/web/service"
+)
+
+type WarpIpJob struct {
+	settingService service.SettingService
+	warpService    service.WarpService
+	xrayService    service.XrayService
+}
+
+func NewWarpIpJob() *WarpIpJob {
+	return &WarpIpJob{}
+}
+
+func (j *WarpIpJob) Run() {
+	allSetting, err := j.settingService.GetAllSetting()
+	if err != nil {
+		return
+	}
+	interval := allSetting.WarpUpdateInterval
+	if interval <= 0 {
+		return
+	}
+
+	lastUpdate, _ := j.settingService.GetWarpLastUpdate()
+	now := time.Now().Unix()
+
+	// First run after the feature is enabled (e.g. interval set via direct
+	// DB edit): establish a baseline instead of rotating immediately.
+	if lastUpdate == 0 {
+		_ = j.settingService.SetWarpLastUpdate(now)
+		return
+	}
+
+	if now-lastUpdate >= int64(interval*24*3600) {
+		logger.Info("Starting scheduled WARP IP update...")
+		_, err := j.warpService.ChangeWarpIP()
+		if err != nil {
+			logger.Warning("Failed to update WARP IP: ", err)
+			return
+		}
+
+		_ = j.settingService.SetWarpLastUpdate(now)
+		j.xrayService.SetToNeedRestart()
+		logger.Info("Successfully updated WARP IP and scheduled Xray restart")
+	}
+}

+ 17 - 0
web/service/setting.go

@@ -89,6 +89,7 @@ var defaultValueMap = map[string]string{
 	"subThemeDir":                 "",
 	"datepicker":                  "gregorian",
 	"warp":                        "",
+	"warpUpdateInterval":          "0",
 	"nord":                        "",
 	"externalTrafficInformEnable": "false",
 	"externalTrafficInformURI":    "",
@@ -323,6 +324,22 @@ func (s *SettingService) setInt(key string, value int) error {
 	return s.setString(key, strconv.Itoa(value))
 }
 
+func (s *SettingService) GetWarpLastUpdate() (int64, error) {
+	val, err := s.getString("warpLastUpdate")
+	if err != nil || val == "" {
+		return 0, err
+	}
+	return strconv.ParseInt(val, 10, 64)
+}
+
+func (s *SettingService) SetWarpLastUpdate(val int64) error {
+	return s.saveSetting("warpLastUpdate", strconv.FormatInt(val, 10))
+}
+
+func (s *SettingService) SetWarpUpdateInterval(val int) error {
+	return s.setInt("warpUpdateInterval", val)
+}
+
 func (s *SettingService) GetXrayConfigTemplate() (string, error) {
 	return s.getString("xrayTemplateConfig")
 }

+ 40 - 0
web/service/warp.go

@@ -9,6 +9,8 @@ import (
 	"os"
 	"time"
 
+	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/util"
 	"github.com/mhsanaei/3x-ui/v3/util/common"
 )
 
@@ -170,6 +172,44 @@ func (s *WarpService) SetWarpLicense(license string) (string, error) {
 	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()

+ 89 - 0
web/service/xray_setting.go

@@ -2,6 +2,7 @@ package service
 
 import (
 	_ "embed"
+	"encoding/base64"
 	"encoding/json"
 	"slices"
 
@@ -40,6 +41,94 @@ func (s *XraySettingService) CheckXrayConfig(XrayTemplateConfig string) error {
 	return nil
 }
 
+func (s *XraySettingService) UpdateWarpXraySetting(warpData map[string]string, warpConfig map[string]interface{}) error {
+	template, err := s.GetXrayConfigTemplate()
+	if err != nil {
+		return err
+	}
+
+	var cfg map[string]interface{}
+	if err := json.Unmarshal([]byte(template), &cfg); err != nil {
+		return err
+	}
+
+	outbounds, ok := cfg["outbounds"].([]interface{})
+	if !ok {
+		return nil
+	}
+
+	updated := false
+	for _, outIface := range outbounds {
+		out, ok := outIface.(map[string]interface{})
+		if !ok {
+			continue
+		}
+		if tag, ok := out["tag"].(string); ok && tag == "warp" {
+			settings, ok := out["settings"].(map[string]interface{})
+			if !ok {
+				continue
+			}
+
+			settings["secretKey"] = warpData["private_key"]
+
+			if conf, ok := warpConfig["config"].(map[string]interface{}); ok {
+				if iface, ok := conf["interface"].(map[string]interface{}); ok {
+					if addrs, ok := iface["addresses"].(map[string]interface{}); ok {
+						var addrList []string
+						if v4, ok := addrs["v4"].(string); ok && v4 != "" {
+							addrList = append(addrList, v4+"/32")
+						}
+						if v6, ok := addrs["v6"].(string); ok && v6 != "" {
+							addrList = append(addrList, v6+"/128")
+						}
+						settings["address"] = addrList
+					}
+				}
+
+				var clientId string
+				if id, ok := conf["client_id"].(string); ok {
+					clientId = id
+				} else if id, ok := warpData["client_id"]; ok {
+					clientId = id
+				}
+				if clientId != "" {
+					decoded, _ := base64.StdEncoding.DecodeString(clientId)
+					var res []int
+					for _, b := range decoded {
+						res = append(res, int(b))
+					}
+					settings["reserved"] = res
+				}
+
+				if peers, ok := conf["peers"].([]interface{}); ok && len(peers) > 0 {
+					if peer, ok := peers[0].(map[string]interface{}); ok {
+						if pSettings, ok := settings["peers"].([]interface{}); ok && len(pSettings) > 0 {
+							if pSet, ok := pSettings[0].(map[string]interface{}); ok {
+								pSet["publicKey"] = peer["public_key"]
+								if endpoint, ok := peer["endpoint"].(map[string]interface{}); ok {
+									pSet["endpoint"] = endpoint["host"]
+								}
+							}
+						}
+					}
+				}
+			}
+			updated = true
+			break
+		}
+	}
+
+	if updated {
+		outJSON, err := json.MarshalIndent(cfg, "", "  ")
+		if err != nil {
+			return err
+		}
+		return s.SaveXraySetting(string(outJSON))
+	}
+
+	return nil
+}
+
 // UnwrapXrayTemplateConfig returns the raw xray config JSON from `raw`,
 // peeling off any number of `{ "inboundTags": ..., "outboundTestUrl": ...,
 // "xraySetting": <real config> }` response-shaped wrappers that may have

+ 5 - 0
web/translation/ar-EG.json

@@ -1455,6 +1455,11 @@
         "outboundUpdated": "تم تحديث صادر NordVPN"
       },
       "warp": {
+        "changeIp": "تغيير الـ IP",
+        "changeIpSuccess": "تم تغيير عنوان IP الخاص بـ WARP بنجاح!",
+        "autoUpdateIp": "التحديث التلقائي لعنوان IP",
+        "intervalDays": "الفاصل الزمني (أيام)",
+        "intervalDesc": "0 للتعطيل. يغيّر عنوان IP تلقائيًا.",
         "licenseError": "فشل تعيين رخصة WARP.",
         "fetchFirst": "احصل على تكوين WARP أولاً.",
         "createAccount": "إنشاء حساب WARP",

+ 5 - 0
web/translation/en-US.json

@@ -1456,6 +1456,11 @@
         "outboundUpdated": "NordVPN outbound updated"
       },
       "warp": {
+        "changeIp": "Change IP",
+        "changeIpSuccess": "WARP IP changed successfully!",
+        "autoUpdateIp": "Auto Update IP Address",
+        "intervalDays": "Interval (Days)",
+        "intervalDesc": "0 to disable. Changes IP address automatically.",
         "licenseError": "Failed to set WARP license.",
         "fetchFirst": "Fetch the WARP config first.",
         "createAccount": "Create WARP account",

+ 5 - 0
web/translation/es-ES.json

@@ -1455,6 +1455,11 @@
         "outboundUpdated": "Salida NordVPN actualizada"
       },
       "warp": {
+        "changeIp": "Cambiar IP",
+        "changeIpSuccess": "¡IP de WARP cambiada correctamente!",
+        "autoUpdateIp": "Actualizar IP automáticamente",
+        "intervalDays": "Intervalo (días)",
+        "intervalDesc": "0 para desactivar. Cambia la dirección IP automáticamente.",
         "licenseError": "No se pudo establecer la licencia WARP.",
         "fetchFirst": "Obtén primero la configuración WARP.",
         "createAccount": "Crear cuenta WARP",

+ 5 - 0
web/translation/fa-IR.json

@@ -1455,6 +1455,11 @@
         "outboundUpdated": "خروجی NordVPN به‌روزرسانی شد"
       },
       "warp": {
+        "changeIp": "تغییر IP",
+        "changeIpSuccess": "آدرس IP وارپ با موفقیت تغییر کرد!",
+        "autoUpdateIp": "به‌روزرسانی خودکار آدرس IP",
+        "intervalDays": "بازه (روز)",
+        "intervalDesc": "برای غیرفعال‌سازی ۰ بگذارید. آدرس IP را به‌صورت خودکار تغییر می‌دهد.",
         "licenseError": "تنظیم لایسنس WARP ناموفق بود.",
         "fetchFirst": "ابتدا پیکربندی WARP را دریافت کنید.",
         "createAccount": "ایجاد حساب WARP",

+ 5 - 0
web/translation/id-ID.json

@@ -1455,6 +1455,11 @@
         "outboundUpdated": "Outbound NordVPN diperbarui"
       },
       "warp": {
+        "changeIp": "Ganti IP",
+        "changeIpSuccess": "IP WARP berhasil diganti!",
+        "autoUpdateIp": "Perbarui Alamat IP Otomatis",
+        "intervalDays": "Interval (Hari)",
+        "intervalDesc": "0 untuk menonaktifkan. Mengganti alamat IP secara otomatis.",
         "licenseError": "Gagal mengatur lisensi WARP.",
         "fetchFirst": "Ambil konfig WARP terlebih dahulu.",
         "createAccount": "Buat akun WARP",

+ 5 - 0
web/translation/ja-JP.json

@@ -1455,6 +1455,11 @@
         "outboundUpdated": "NordVPN アウトバウンドを更新しました"
       },
       "warp": {
+        "changeIp": "IP を変更",
+        "changeIpSuccess": "WARP の IP を変更しました!",
+        "autoUpdateIp": "IP アドレスの自動更新",
+        "intervalDays": "間隔(日)",
+        "intervalDesc": "0 で無効。IP アドレスを自動的に変更します。",
         "licenseError": "WARP ライセンスの設定に失敗しました。",
         "fetchFirst": "先に WARP 構成を取得してください。",
         "createAccount": "WARP アカウントを作成",

+ 5 - 0
web/translation/pt-BR.json

@@ -1455,6 +1455,11 @@
         "outboundUpdated": "Saída NordVPN atualizada"
       },
       "warp": {
+        "changeIp": "Alterar IP",
+        "changeIpSuccess": "IP do WARP alterado com sucesso!",
+        "autoUpdateIp": "Atualizar endereço IP automaticamente",
+        "intervalDays": "Intervalo (dias)",
+        "intervalDesc": "0 para desativar. Altera o endereço IP automaticamente.",
         "licenseError": "Falha ao definir licença WARP.",
         "fetchFirst": "Obtenha primeiro a configuração WARP.",
         "createAccount": "Criar conta WARP",

+ 5 - 0
web/translation/ru-RU.json

@@ -1455,6 +1455,11 @@
         "outboundUpdated": "Исходящий NordVPN обновлён"
       },
       "warp": {
+        "changeIp": "Сменить IP",
+        "changeIpSuccess": "IP-адрес WARP успешно изменён!",
+        "autoUpdateIp": "Автоматическое обновление IP-адреса",
+        "intervalDays": "Интервал (дни)",
+        "intervalDesc": "0 — отключить. Автоматически меняет IP-адрес.",
         "licenseError": "Не удалось установить лицензию WARP.",
         "fetchFirst": "Сначала получите WARP-конфиг.",
         "createAccount": "Создать аккаунт WARP",

+ 5 - 0
web/translation/tr-TR.json

@@ -1454,6 +1454,11 @@
         "outboundUpdated": "NordVPN giden bağlantı güncellendi."
       },
       "warp": {
+        "changeIp": "IP Değiştir",
+        "changeIpSuccess": "WARP IP adresi başarıyla değiştirildi!",
+        "autoUpdateIp": "IP Adresini Otomatik Güncelle",
+        "intervalDays": "Aralık (Gün)",
+        "intervalDesc": "Devre dışı bırakmak için 0. IP adresini otomatik olarak değiştirir.",
         "licenseError": "WARP lisansı ayarlanamadı.",
         "fetchFirst": "Önce WARP yapılandırmasını alın.",
         "createAccount": "WARP Hesabı Oluştur",

+ 5 - 0
web/translation/uk-UA.json

@@ -1455,6 +1455,11 @@
         "outboundUpdated": "Вихідний NordVPN оновлено"
       },
       "warp": {
+        "changeIp": "Змінити IP",
+        "changeIpSuccess": "IP-адресу WARP успішно змінено!",
+        "autoUpdateIp": "Автоматичне оновлення IP-адреси",
+        "intervalDays": "Інтервал (дні)",
+        "intervalDesc": "0 — вимкнути. Автоматично змінює IP-адресу.",
         "licenseError": "Не вдалося встановити ліцензію WARP.",
         "fetchFirst": "Спочатку отримайте WARP-конфіг.",
         "createAccount": "Створити акаунт WARP",

+ 5 - 0
web/translation/vi-VN.json

@@ -1455,6 +1455,11 @@
         "outboundUpdated": "Đã cập nhật outbound NordVPN"
       },
       "warp": {
+        "changeIp": "Đổi IP",
+        "changeIpSuccess": "Đã đổi IP WARP thành công!",
+        "autoUpdateIp": "Tự động cập nhật địa chỉ IP",
+        "intervalDays": "Khoảng thời gian (ngày)",
+        "intervalDesc": "0 để tắt. Tự động đổi địa chỉ IP.",
         "licenseError": "Không thiết lập được giấy phép WARP.",
         "fetchFirst": "Hãy lấy cấu hình WARP trước.",
         "createAccount": "Tạo tài khoản WARP",

+ 5 - 0
web/translation/zh-CN.json

@@ -1455,6 +1455,11 @@
         "outboundUpdated": "NordVPN 出站已更新"
       },
       "warp": {
+        "changeIp": "更换 IP",
+        "changeIpSuccess": "WARP IP 更换成功!",
+        "autoUpdateIp": "自动更新 IP 地址",
+        "intervalDays": "间隔(天)",
+        "intervalDesc": "设为 0 禁用。自动更换 IP 地址。",
         "licenseError": "设置 WARP 许可证失败。",
         "fetchFirst": "请先获取 WARP 配置。",
         "createAccount": "创建 WARP 账户",

+ 5 - 0
web/translation/zh-TW.json

@@ -1455,6 +1455,11 @@
         "outboundUpdated": "NordVPN 出站已更新"
       },
       "warp": {
+        "changeIp": "更換 IP",
+        "changeIpSuccess": "WARP IP 更換成功!",
+        "autoUpdateIp": "自動更新 IP 位址",
+        "intervalDays": "間隔(天)",
+        "intervalDesc": "設為 0 停用。自動更換 IP 位址。",
         "licenseError": "設定 WARP 授權失敗。",
         "fetchFirst": "請先取得 WARP 設定。",
         "createAccount": "建立 WARP 帳號",

+ 1 - 0
web/web.go

@@ -299,6 +299,7 @@ func (s *Server) startTask(restartXray bool) {
 
 	// check client ips from log file every day
 	s.cron.AddJob("@daily", job.NewClearLogsJob())
+	s.cron.AddJob("@hourly", job.NewWarpIpJob())
 
 	// Inbound traffic reset jobs
 	// Run every hour