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 <Rqzbeh@example.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
Rouzbeh†
2026-06-09 01:43:43 +02:00
committed by GitHub
parent be8bd4e22c
commit d9ccf157c3
27 changed files with 436 additions and 28 deletions
+26 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -1455,6 +1455,11 @@
"outboundUpdated": "خروجی NordVPN به‌روزرسانی شد"
},
"warp": {
"changeIp": "تغییر IP",
"changeIpSuccess": "آدرس IP وارپ با موفقیت تغییر کرد!",
"autoUpdateIp": "به‌روزرسانی خودکار آدرس IP",
"intervalDays": "بازه (روز)",
"intervalDesc": "برای غیرفعال‌سازی ۰ بگذارید. آدرس IP را به‌صورت خودکار تغییر می‌دهد.",
"licenseError": "تنظیم لایسنس WARP ناموفق بود.",
"fetchFirst": "ابتدا پیکربندی WARP را دریافت کنید.",
"createAccount": "ایجاد حساب WARP",
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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