mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-13 16:16:06 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1455,6 +1455,11 @@
|
||||
"outboundUpdated": "تم تحديث صادر NordVPN"
|
||||
},
|
||||
"warp": {
|
||||
"changeIp": "تغيير الـ IP",
|
||||
"changeIpSuccess": "تم تغيير عنوان IP الخاص بـ WARP بنجاح!",
|
||||
"autoUpdateIp": "التحديث التلقائي لعنوان IP",
|
||||
"intervalDays": "الفاصل الزمني (أيام)",
|
||||
"intervalDesc": "0 للتعطيل. يغيّر عنوان IP تلقائيًا.",
|
||||
"licenseError": "فشل تعيين رخصة WARP.",
|
||||
"fetchFirst": "احصل على تكوين WARP أولاً.",
|
||||
"createAccount": "إنشاء حساب WARP",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1455,6 +1455,11 @@
|
||||
"outboundUpdated": "خروجی NordVPN بهروزرسانی شد"
|
||||
},
|
||||
"warp": {
|
||||
"changeIp": "تغییر IP",
|
||||
"changeIpSuccess": "آدرس IP وارپ با موفقیت تغییر کرد!",
|
||||
"autoUpdateIp": "بهروزرسانی خودکار آدرس IP",
|
||||
"intervalDays": "بازه (روز)",
|
||||
"intervalDesc": "برای غیرفعالسازی ۰ بگذارید. آدرس IP را بهصورت خودکار تغییر میدهد.",
|
||||
"licenseError": "تنظیم لایسنس WARP ناموفق بود.",
|
||||
"fetchFirst": "ابتدا پیکربندی WARP را دریافت کنید.",
|
||||
"createAccount": "ایجاد حساب WARP",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1455,6 +1455,11 @@
|
||||
"outboundUpdated": "NordVPN アウトバウンドを更新しました"
|
||||
},
|
||||
"warp": {
|
||||
"changeIp": "IP を変更",
|
||||
"changeIpSuccess": "WARP の IP を変更しました!",
|
||||
"autoUpdateIp": "IP アドレスの自動更新",
|
||||
"intervalDays": "間隔(日)",
|
||||
"intervalDesc": "0 で無効。IP アドレスを自動的に変更します。",
|
||||
"licenseError": "WARP ライセンスの設定に失敗しました。",
|
||||
"fetchFirst": "先に WARP 構成を取得してください。",
|
||||
"createAccount": "WARP アカウントを作成",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1455,6 +1455,11 @@
|
||||
"outboundUpdated": "Исходящий NordVPN обновлён"
|
||||
},
|
||||
"warp": {
|
||||
"changeIp": "Сменить IP",
|
||||
"changeIpSuccess": "IP-адрес WARP успешно изменён!",
|
||||
"autoUpdateIp": "Автоматическое обновление IP-адреса",
|
||||
"intervalDays": "Интервал (дни)",
|
||||
"intervalDesc": "0 — отключить. Автоматически меняет IP-адрес.",
|
||||
"licenseError": "Не удалось установить лицензию WARP.",
|
||||
"fetchFirst": "Сначала получите WARP-конфиг.",
|
||||
"createAccount": "Создать аккаунт WARP",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1455,6 +1455,11 @@
|
||||
"outboundUpdated": "Вихідний NordVPN оновлено"
|
||||
},
|
||||
"warp": {
|
||||
"changeIp": "Змінити IP",
|
||||
"changeIpSuccess": "IP-адресу WARP успішно змінено!",
|
||||
"autoUpdateIp": "Автоматичне оновлення IP-адреси",
|
||||
"intervalDays": "Інтервал (дні)",
|
||||
"intervalDesc": "0 — вимкнути. Автоматично змінює IP-адресу.",
|
||||
"licenseError": "Не вдалося встановити ліцензію WARP.",
|
||||
"fetchFirst": "Спочатку отримайте WARP-конфіг.",
|
||||
"createAccount": "Створити акаунт WARP",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1455,6 +1455,11 @@
|
||||
"outboundUpdated": "NordVPN 出站已更新"
|
||||
},
|
||||
"warp": {
|
||||
"changeIp": "更换 IP",
|
||||
"changeIpSuccess": "WARP IP 更换成功!",
|
||||
"autoUpdateIp": "自动更新 IP 地址",
|
||||
"intervalDays": "间隔(天)",
|
||||
"intervalDesc": "设为 0 禁用。自动更换 IP 地址。",
|
||||
"licenseError": "设置 WARP 许可证失败。",
|
||||
"fetchFirst": "请先获取 WARP 配置。",
|
||||
"createAccount": "创建 WARP 账户",
|
||||
|
||||
@@ -1455,6 +1455,11 @@
|
||||
"outboundUpdated": "NordVPN 出站已更新"
|
||||
},
|
||||
"warp": {
|
||||
"changeIp": "更換 IP",
|
||||
"changeIpSuccess": "WARP IP 更換成功!",
|
||||
"autoUpdateIp": "自動更新 IP 位址",
|
||||
"intervalDays": "間隔(天)",
|
||||
"intervalDesc": "設為 0 停用。自動更換 IP 位址。",
|
||||
"licenseError": "設定 WARP 授權失敗。",
|
||||
"fetchFirst": "請先取得 WARP 設定。",
|
||||
"createAccount": "建立 WARP 帳號",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user