mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-23 11:27:13 +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:
@@ -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",
|
||||||
|
|||||||
@@ -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": "",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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 && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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": "",
|
"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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 アカウントを作成",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 账户",
|
||||||
|
|||||||
@@ -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 帳號",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user