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", "description": "Two-factor authentication token",
"type": "string" "type": "string"
}, },
"warpUpdateInterval": {
"description": "WARP",
"minimum": 0,
"type": "integer"
},
"webBasePath": { "webBasePath": {
"description": "Base path for web panel URLs", "description": "Base path for web panel URLs",
"type": "string" "type": "string"
@@ -427,6 +432,7 @@
"trustedProxyCIDRs", "trustedProxyCIDRs",
"twoFactorEnable", "twoFactorEnable",
"twoFactorToken", "twoFactorToken",
"warpUpdateInterval",
"webBasePath", "webBasePath",
"webCertFile", "webCertFile",
"webDomain", "webDomain",
@@ -753,6 +759,11 @@
"description": "Two-factor authentication token", "description": "Two-factor authentication token",
"type": "string" "type": "string"
}, },
"warpUpdateInterval": {
"description": "WARP",
"minimum": 0,
"type": "integer"
},
"webBasePath": { "webBasePath": {
"description": "Base path for web panel URLs", "description": "Base path for web panel URLs",
"type": "string" "type": "string"
@@ -861,6 +872,7 @@
"trustedProxyCIDRs", "trustedProxyCIDRs",
"twoFactorEnable", "twoFactorEnable",
"twoFactorToken", "twoFactorToken",
"warpUpdateInterval",
"webBasePath", "webBasePath",
"webCertFile", "webCertFile",
"webDomain", "webDomain",
+2
View File
@@ -75,6 +75,7 @@ export const EXAMPLES: Record<string, unknown> = {
"trustedProxyCIDRs": "", "trustedProxyCIDRs": "",
"twoFactorEnable": false, "twoFactorEnable": false,
"twoFactorToken": "", "twoFactorToken": "",
"warpUpdateInterval": 0,
"webBasePath": "", "webBasePath": "",
"webCertFile": "", "webCertFile": "",
"webDomain": "", "webDomain": "",
@@ -163,6 +164,7 @@ export const EXAMPLES: Record<string, unknown> = {
"trustedProxyCIDRs": "", "trustedProxyCIDRs": "",
"twoFactorEnable": false, "twoFactorEnable": false,
"twoFactorToken": "", "twoFactorToken": "",
"warpUpdateInterval": 0,
"webBasePath": "", "webBasePath": "",
"webCertFile": "", "webCertFile": "",
"webDomain": "", "webDomain": "",
+12
View File
@@ -299,6 +299,11 @@ export const SCHEMAS: Record<string, unknown> = {
"description": "Two-factor authentication token", "description": "Two-factor authentication token",
"type": "string" "type": "string"
}, },
"warpUpdateInterval": {
"description": "WARP",
"minimum": 0,
"type": "integer"
},
"webBasePath": { "webBasePath": {
"description": "Base path for web panel URLs", "description": "Base path for web panel URLs",
"type": "string" "type": "string"
@@ -401,6 +406,7 @@ export const SCHEMAS: Record<string, unknown> = {
"trustedProxyCIDRs", "trustedProxyCIDRs",
"twoFactorEnable", "twoFactorEnable",
"twoFactorToken", "twoFactorToken",
"warpUpdateInterval",
"webBasePath", "webBasePath",
"webCertFile", "webCertFile",
"webDomain", "webDomain",
@@ -727,6 +733,11 @@ export const SCHEMAS: Record<string, unknown> = {
"description": "Two-factor authentication token", "description": "Two-factor authentication token",
"type": "string" "type": "string"
}, },
"warpUpdateInterval": {
"description": "WARP",
"minimum": 0,
"type": "integer"
},
"webBasePath": { "webBasePath": {
"description": "Base path for web panel URLs", "description": "Base path for web panel URLs",
"type": "string" "type": "string"
@@ -835,6 +846,7 @@ export const SCHEMAS: Record<string, unknown> = {
"trustedProxyCIDRs", "trustedProxyCIDRs",
"twoFactorEnable", "twoFactorEnable",
"twoFactorToken", "twoFactorToken",
"warpUpdateInterval",
"webBasePath", "webBasePath",
"webCertFile", "webCertFile",
"webDomain", "webDomain",
+2
View File
@@ -80,6 +80,7 @@ export interface AllSetting {
trustedProxyCIDRs: string; trustedProxyCIDRs: string;
twoFactorEnable: boolean; twoFactorEnable: boolean;
twoFactorToken: string; twoFactorToken: string;
warpUpdateInterval: number;
webBasePath: string; webBasePath: string;
webCertFile: string; webCertFile: string;
webDomain: string; webDomain: string;
@@ -169,6 +170,7 @@ export interface AllSettingView {
trustedProxyCIDRs: string; trustedProxyCIDRs: string;
twoFactorEnable: boolean; twoFactorEnable: boolean;
twoFactorToken: string; twoFactorToken: string;
warpUpdateInterval: number;
webBasePath: string; webBasePath: string;
webCertFile: string; webCertFile: string;
webDomain: string; webDomain: string;
+2
View File
@@ -90,6 +90,7 @@ export const AllSettingSchema = z.object({
trustedProxyCIDRs: z.string(), trustedProxyCIDRs: z.string(),
twoFactorEnable: z.boolean(), twoFactorEnable: z.boolean(),
twoFactorToken: z.string(), twoFactorToken: z.string(),
warpUpdateInterval: z.number().int().min(0),
webBasePath: z.string(), webBasePath: z.string(),
webCertFile: z.string(), webCertFile: z.string(),
webDomain: z.string(), webDomain: z.string(),
@@ -180,6 +181,7 @@ export const AllSettingViewSchema = z.object({
trustedProxyCIDRs: z.string(), trustedProxyCIDRs: z.string(),
twoFactorEnable: z.boolean(), twoFactorEnable: z.boolean(),
twoFactorToken: z.string(), twoFactorToken: z.string(),
warpUpdateInterval: z.number().int().min(0),
webBasePath: z.string(), webBasePath: z.string(),
webCertFile: z.string(), webCertFile: z.string(),
webDomain: 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 [warpData, setWarpData] = useState<WarpData | null>(null);
const [warpConfig, setWarpConfig] = useState<WarpConfig | null>(null); const [warpConfig, setWarpConfig] = useState<WarpConfig | null>(null);
const [warpPlus, setWarpPlus] = useState(''); const [warpPlus, setWarpPlus] = useState('');
const [updateInterval, setUpdateInterval] = useState<number>(0);
const [licenseError, setLicenseError] = useState(''); const [licenseError, setLicenseError] = useState('');
const [stagedOutbound, setStagedOutbound] = useState<Record<string, unknown> | null>(null); const [stagedOutbound, setStagedOutbound] = useState<Record<string, unknown> | null>(null);
@@ -89,24 +90,29 @@ export default function WarpModal({
return list.findIndex((o) => o?.tag === 'warp'); return list.findIndex((o) => o?.tag === 'warp');
}, [templateSettings?.outbounds]); }, [templateSettings?.outbounds]);
const collectConfig = useCallback((data: WarpData | null, config: WarpConfig | null) => { const collectConfig = useCallback(
const cfg = config?.config; (data: WarpData | null, config: WarpConfig | null): Record<string, unknown> | null => {
if (!cfg?.peers?.length) return; const cfg = config?.config;
const peer = cfg.peers[0]; if (!cfg?.peers?.length) return null;
setStagedOutbound({ const peer = cfg.peers[0];
tag: 'warp', const outbound: Record<string, unknown> = {
protocol: 'wireguard', tag: 'warp',
settings: { protocol: 'wireguard',
mtu: 1420, settings: {
secretKey: data?.private_key, mtu: 1420,
address: addressesFor(cfg.interface?.addresses || {}), secretKey: data?.private_key,
reserved: reservedFor(cfg.client_id ?? data?.client_id), address: addressesFor(cfg.interface?.addresses || {}),
domainStrategy: 'ForceIP', reserved: reservedFor(cfg.client_id ?? data?.client_id),
peers: [{ publicKey: peer.public_key, endpoint: peer.endpoint?.host }], domainStrategy: 'ForceIP',
noKernelTun: false, peers: [{ publicKey: peer.public_key, endpoint: peer.endpoint?.host }],
}, noKernelTun: false,
}); },
}, []); };
setStagedOutbound(outbound);
return outbound;
},
[],
);
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -116,6 +122,10 @@ export default function WarpModal({
const raw = msg.obj; const raw = msg.obj;
setWarpData(raw && raw.length > 0 ? JSON.parse(raw) : null); 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 { } finally {
setLoading(false); 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() { async function updateLicense() {
if (warpPlus.length < 26) return; if (warpPlus.length < 26) return;
setLoading(true); setLoading(true);
@@ -281,13 +325,37 @@ export default function WarpModal({
</Form> </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> <Divider className="zero-margin">{t('pages.xray.warp.accountInfo')}</Divider>
<Button className="my-8" loading={loading} type="primary" icon={<SyncOutlined />} onClick={getConfig}> <div className="my-8">
{t('refresh')} <Button loading={loading} type="primary" icon={<SyncOutlined />} onClick={getConfig}>
</Button> {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 && ( {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 ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"strconv"
"time"
"github.com/mhsanaei/3x-ui/v3/util/common" "github.com/mhsanaei/3x-ui/v3/util/common"
"github.com/mhsanaei/3x-ui/v3/web/service" "github.com/mhsanaei/3x-ui/v3/web/service"
@@ -12,13 +14,13 @@ import (
// XraySettingController handles Xray configuration and settings operations. // XraySettingController handles Xray configuration and settings operations.
type XraySettingController struct { type XraySettingController struct {
XraySettingService service.XraySettingService XraySettingService service.XraySettingService
SettingService service.SettingService SettingService service.SettingService
InboundService service.InboundService InboundService service.InboundService
OutboundService service.OutboundService OutboundService service.OutboundService
XrayService service.XrayService XrayService service.XrayService
WarpService service.WarpService WarpService service.WarpService
NordService service.NordService NordService service.NordService
OutboundSubscriptionService service.OutboundSubscriptionService OutboundSubscriptionService service.OutboundSubscriptionService
} }
@@ -165,9 +167,26 @@ func (a *XraySettingController) warp(c *gin.Context) {
skey := c.PostForm("privateKey") skey := c.PostForm("privateKey")
pkey := c.PostForm("publicKey") pkey := c.PostForm("publicKey")
resp, err = a.WarpService.RegWarp(skey, pkey) 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": case "license":
license := c.PostForm("license") license := c.PostForm("license")
resp, err = a.WarpService.SetWarpLicense(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) jsonObj(c, resp, err)
+3
View File
@@ -113,6 +113,9 @@ type AllSetting struct {
LdapDefaultExpiryDays int `json:"ldapDefaultExpiryDays" form:"ldapDefaultExpiryDays" validate:"gte=0"` LdapDefaultExpiryDays int `json:"ldapDefaultExpiryDays" form:"ldapDefaultExpiryDays" validate:"gte=0"`
LdapDefaultLimitIP int `json:"ldapDefaultLimitIP" form:"ldapDefaultLimitIP" validate:"gte=0"` LdapDefaultLimitIP int `json:"ldapDefaultLimitIP" form:"ldapDefaultLimitIP" validate:"gte=0"`
// JSON subscription routing rules // JSON subscription routing rules
// WARP
WarpUpdateInterval int `json:"warpUpdateInterval" form:"warpUpdateInterval" validate:"gte=0"`
} }
// AllSettingView is the browser-safe settings read model. Secret values // 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": "", "subThemeDir": "",
"datepicker": "gregorian", "datepicker": "gregorian",
"warp": "", "warp": "",
"warpUpdateInterval": "0",
"nord": "", "nord": "",
"externalTrafficInformEnable": "false", "externalTrafficInformEnable": "false",
"externalTrafficInformURI": "", "externalTrafficInformURI": "",
@@ -323,6 +324,22 @@ func (s *SettingService) setInt(key string, value int) error {
return s.setString(key, strconv.Itoa(value)) 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) { func (s *SettingService) GetXrayConfigTemplate() (string, error) {
return s.getString("xrayTemplateConfig") return s.getString("xrayTemplateConfig")
} }
+40
View File
@@ -9,6 +9,8 @@ import (
"os" "os"
"time" "time"
"github.com/mhsanaei/3x-ui/v3/logger"
"github.com/mhsanaei/3x-ui/v3/util"
"github.com/mhsanaei/3x-ui/v3/util/common" "github.com/mhsanaei/3x-ui/v3/util/common"
) )
@@ -170,6 +172,44 @@ func (s *WarpService) SetWarpLicense(license string) (string, error) {
return string(newWarpData), nil 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. // loadWarpCreds reads the stored warp JSON and ensures access_token + device_id are set.
func (s *WarpService) loadWarpCreds() (map[string]string, error) { func (s *WarpService) loadWarpCreds() (map[string]string, error) {
warp, err := s.SettingService.GetWarp() warp, err := s.SettingService.GetWarp()
+89
View File
@@ -2,6 +2,7 @@ package service
import ( import (
_ "embed" _ "embed"
"encoding/base64"
"encoding/json" "encoding/json"
"slices" "slices"
@@ -40,6 +41,94 @@ func (s *XraySettingService) CheckXrayConfig(XrayTemplateConfig string) error {
return nil 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`, // UnwrapXrayTemplateConfig returns the raw xray config JSON from `raw`,
// peeling off any number of `{ "inboundTags": ..., "outboundTestUrl": ..., // peeling off any number of `{ "inboundTags": ..., "outboundTestUrl": ...,
// "xraySetting": <real config> }` response-shaped wrappers that may have // "xraySetting": <real config> }` response-shaped wrappers that may have
+5
View File
@@ -1455,6 +1455,11 @@
"outboundUpdated": "تم تحديث صادر NordVPN" "outboundUpdated": "تم تحديث صادر NordVPN"
}, },
"warp": { "warp": {
"changeIp": "تغيير الـ IP",
"changeIpSuccess": "تم تغيير عنوان IP الخاص بـ WARP بنجاح!",
"autoUpdateIp": "التحديث التلقائي لعنوان IP",
"intervalDays": "الفاصل الزمني (أيام)",
"intervalDesc": "0 للتعطيل. يغيّر عنوان IP تلقائيًا.",
"licenseError": "فشل تعيين رخصة WARP.", "licenseError": "فشل تعيين رخصة WARP.",
"fetchFirst": "احصل على تكوين WARP أولاً.", "fetchFirst": "احصل على تكوين WARP أولاً.",
"createAccount": "إنشاء حساب WARP", "createAccount": "إنشاء حساب WARP",
+5
View File
@@ -1456,6 +1456,11 @@
"outboundUpdated": "NordVPN outbound updated" "outboundUpdated": "NordVPN outbound updated"
}, },
"warp": { "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.", "licenseError": "Failed to set WARP license.",
"fetchFirst": "Fetch the WARP config first.", "fetchFirst": "Fetch the WARP config first.",
"createAccount": "Create WARP account", "createAccount": "Create WARP account",
+5
View File
@@ -1455,6 +1455,11 @@
"outboundUpdated": "Salida NordVPN actualizada" "outboundUpdated": "Salida NordVPN actualizada"
}, },
"warp": { "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.", "licenseError": "No se pudo establecer la licencia WARP.",
"fetchFirst": "Obtén primero la configuración WARP.", "fetchFirst": "Obtén primero la configuración WARP.",
"createAccount": "Crear cuenta WARP", "createAccount": "Crear cuenta WARP",
+5
View File
@@ -1455,6 +1455,11 @@
"outboundUpdated": "خروجی NordVPN به‌روزرسانی شد" "outboundUpdated": "خروجی NordVPN به‌روزرسانی شد"
}, },
"warp": { "warp": {
"changeIp": "تغییر IP",
"changeIpSuccess": "آدرس IP وارپ با موفقیت تغییر کرد!",
"autoUpdateIp": "به‌روزرسانی خودکار آدرس IP",
"intervalDays": "بازه (روز)",
"intervalDesc": "برای غیرفعال‌سازی ۰ بگذارید. آدرس IP را به‌صورت خودکار تغییر می‌دهد.",
"licenseError": "تنظیم لایسنس WARP ناموفق بود.", "licenseError": "تنظیم لایسنس WARP ناموفق بود.",
"fetchFirst": "ابتدا پیکربندی WARP را دریافت کنید.", "fetchFirst": "ابتدا پیکربندی WARP را دریافت کنید.",
"createAccount": "ایجاد حساب WARP", "createAccount": "ایجاد حساب WARP",
+5
View File
@@ -1455,6 +1455,11 @@
"outboundUpdated": "Outbound NordVPN diperbarui" "outboundUpdated": "Outbound NordVPN diperbarui"
}, },
"warp": { "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.", "licenseError": "Gagal mengatur lisensi WARP.",
"fetchFirst": "Ambil konfig WARP terlebih dahulu.", "fetchFirst": "Ambil konfig WARP terlebih dahulu.",
"createAccount": "Buat akun WARP", "createAccount": "Buat akun WARP",
+5
View File
@@ -1455,6 +1455,11 @@
"outboundUpdated": "NordVPN アウトバウンドを更新しました" "outboundUpdated": "NordVPN アウトバウンドを更新しました"
}, },
"warp": { "warp": {
"changeIp": "IP を変更",
"changeIpSuccess": "WARP の IP を変更しました!",
"autoUpdateIp": "IP アドレスの自動更新",
"intervalDays": "間隔(日)",
"intervalDesc": "0 で無効。IP アドレスを自動的に変更します。",
"licenseError": "WARP ライセンスの設定に失敗しました。", "licenseError": "WARP ライセンスの設定に失敗しました。",
"fetchFirst": "先に WARP 構成を取得してください。", "fetchFirst": "先に WARP 構成を取得してください。",
"createAccount": "WARP アカウントを作成", "createAccount": "WARP アカウントを作成",
+5
View File
@@ -1455,6 +1455,11 @@
"outboundUpdated": "Saída NordVPN atualizada" "outboundUpdated": "Saída NordVPN atualizada"
}, },
"warp": { "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.", "licenseError": "Falha ao definir licença WARP.",
"fetchFirst": "Obtenha primeiro a configuração WARP.", "fetchFirst": "Obtenha primeiro a configuração WARP.",
"createAccount": "Criar conta WARP", "createAccount": "Criar conta WARP",
+5
View File
@@ -1455,6 +1455,11 @@
"outboundUpdated": "Исходящий NordVPN обновлён" "outboundUpdated": "Исходящий NordVPN обновлён"
}, },
"warp": { "warp": {
"changeIp": "Сменить IP",
"changeIpSuccess": "IP-адрес WARP успешно изменён!",
"autoUpdateIp": "Автоматическое обновление IP-адреса",
"intervalDays": "Интервал (дни)",
"intervalDesc": "0 — отключить. Автоматически меняет IP-адрес.",
"licenseError": "Не удалось установить лицензию WARP.", "licenseError": "Не удалось установить лицензию WARP.",
"fetchFirst": "Сначала получите WARP-конфиг.", "fetchFirst": "Сначала получите WARP-конфиг.",
"createAccount": "Создать аккаунт WARP", "createAccount": "Создать аккаунт WARP",
+5
View File
@@ -1454,6 +1454,11 @@
"outboundUpdated": "NordVPN giden bağlantı güncellendi." "outboundUpdated": "NordVPN giden bağlantı güncellendi."
}, },
"warp": { "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ı.", "licenseError": "WARP lisansı ayarlanamadı.",
"fetchFirst": "Önce WARP yapılandırmasını alın.", "fetchFirst": "Önce WARP yapılandırmasını alın.",
"createAccount": "WARP Hesabı Oluştur", "createAccount": "WARP Hesabı Oluştur",
+5
View File
@@ -1455,6 +1455,11 @@
"outboundUpdated": "Вихідний NordVPN оновлено" "outboundUpdated": "Вихідний NordVPN оновлено"
}, },
"warp": { "warp": {
"changeIp": "Змінити IP",
"changeIpSuccess": "IP-адресу WARP успішно змінено!",
"autoUpdateIp": "Автоматичне оновлення IP-адреси",
"intervalDays": "Інтервал (дні)",
"intervalDesc": "0 — вимкнути. Автоматично змінює IP-адресу.",
"licenseError": "Не вдалося встановити ліцензію WARP.", "licenseError": "Не вдалося встановити ліцензію WARP.",
"fetchFirst": "Спочатку отримайте WARP-конфіг.", "fetchFirst": "Спочатку отримайте WARP-конфіг.",
"createAccount": "Створити акаунт WARP", "createAccount": "Створити акаунт WARP",
+5
View File
@@ -1455,6 +1455,11 @@
"outboundUpdated": "Đã cập nhật outbound NordVPN" "outboundUpdated": "Đã cập nhật outbound NordVPN"
}, },
"warp": { "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.", "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.", "fetchFirst": "Hãy lấy cấu hình WARP trước.",
"createAccount": "Tạo tài khoản WARP", "createAccount": "Tạo tài khoản WARP",
+5
View File
@@ -1455,6 +1455,11 @@
"outboundUpdated": "NordVPN 出站已更新" "outboundUpdated": "NordVPN 出站已更新"
}, },
"warp": { "warp": {
"changeIp": "更换 IP",
"changeIpSuccess": "WARP IP 更换成功!",
"autoUpdateIp": "自动更新 IP 地址",
"intervalDays": "间隔(天)",
"intervalDesc": "设为 0 禁用。自动更换 IP 地址。",
"licenseError": "设置 WARP 许可证失败。", "licenseError": "设置 WARP 许可证失败。",
"fetchFirst": "请先获取 WARP 配置。", "fetchFirst": "请先获取 WARP 配置。",
"createAccount": "创建 WARP 账户", "createAccount": "创建 WARP 账户",
+5
View File
@@ -1455,6 +1455,11 @@
"outboundUpdated": "NordVPN 出站已更新" "outboundUpdated": "NordVPN 出站已更新"
}, },
"warp": { "warp": {
"changeIp": "更換 IP",
"changeIpSuccess": "WARP IP 更換成功!",
"autoUpdateIp": "自動更新 IP 位址",
"intervalDays": "間隔(天)",
"intervalDesc": "設為 0 停用。自動更換 IP 位址。",
"licenseError": "設定 WARP 授權失敗。", "licenseError": "設定 WARP 授權失敗。",
"fetchFirst": "請先取得 WARP 設定。", "fetchFirst": "請先取得 WARP 設定。",
"createAccount": "建立 WARP 帳號", "createAccount": "建立 WARP 帳號",
+1
View File
@@ -299,6 +299,7 @@ func (s *Server) startTask(restartXray bool) {
// check client ips from log file every day // check client ips from log file every day
s.cron.AddJob("@daily", job.NewClearLogsJob()) s.cron.AddJob("@daily", job.NewClearLogsJob())
s.cron.AddJob("@hourly", job.NewWarpIpJob())
// Inbound traffic reset jobs // Inbound traffic reset jobs
// Run every hour // Run every hour