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
+12
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
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