mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 21:24:10 +00:00
Merge branch 'main' into feat/daily-traffic-speed
This commit is contained in:
commit
c6cf569852
16 changed files with 542 additions and 183 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,6 +1,7 @@
|
||||||
# Ignore editor and IDE settings
|
# Ignore editor and IDE settings
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.claude/
|
||||||
.cache/
|
.cache/
|
||||||
.sync*
|
.sync*
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,61 @@
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
# Start fail2ban
|
# Start fail2ban with the 3x-ipl jail
|
||||||
[ $XUI_ENABLE_FAIL2BAN == "true" ] && fail2ban-client -x start
|
if [ "$XUI_ENABLE_FAIL2BAN" = "true" ]; then
|
||||||
|
LOG_FOLDER="${XUI_LOG_FOLDER:-/var/log/x-ui}"
|
||||||
|
mkdir -p "$LOG_FOLDER"
|
||||||
|
touch "$LOG_FOLDER/3xipl.log" "$LOG_FOLDER/3xipl-banned.log"
|
||||||
|
|
||||||
|
mkdir -p /etc/fail2ban/jail.d /etc/fail2ban/filter.d /etc/fail2ban/action.d
|
||||||
|
|
||||||
|
cat > /etc/fail2ban/jail.d/3x-ipl.conf << EOF
|
||||||
|
[3x-ipl]
|
||||||
|
enabled=true
|
||||||
|
backend=auto
|
||||||
|
filter=3x-ipl
|
||||||
|
action=3x-ipl
|
||||||
|
logpath=$LOG_FOLDER/3xipl.log
|
||||||
|
maxretry=1
|
||||||
|
findtime=32
|
||||||
|
bantime=30m
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > /etc/fail2ban/filter.d/3x-ipl.conf << 'EOF'
|
||||||
|
[Definition]
|
||||||
|
datepattern = ^%Y/%m/%d %H:%M:%S
|
||||||
|
failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnecting OLD IP\s*=\s*<ADDR>\s*\|\|\s*Timestamp\s*=\s*\d+
|
||||||
|
ignoreregex =
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > /etc/fail2ban/action.d/3x-ipl.conf << EOF
|
||||||
|
[INCLUDES]
|
||||||
|
before = iptables-allports.conf
|
||||||
|
|
||||||
|
[Definition]
|
||||||
|
actionstart = <iptables> -N f2b-<name>
|
||||||
|
<iptables> -A f2b-<name> -j <returntype>
|
||||||
|
<iptables> -I <chain> -p <protocol> -j f2b-<name>
|
||||||
|
|
||||||
|
actionstop = <iptables> -D <chain> -p <protocol> -j f2b-<name>
|
||||||
|
<actionflush>
|
||||||
|
<iptables> -X f2b-<name>
|
||||||
|
|
||||||
|
actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]'
|
||||||
|
|
||||||
|
actionban = <iptables> -I f2b-<name> 1 -s <ip> -j <blocktype>
|
||||||
|
echo "\$(date +"%Y/%m/%d %H:%M:%S") BAN [Email] = <F-USER> [IP] = <ip> banned for <bantime> seconds." >> $LOG_FOLDER/3xipl-banned.log
|
||||||
|
|
||||||
|
actionunban = <iptables> -D f2b-<name> -s <ip> -j <blocktype>
|
||||||
|
echo "\$(date +"%Y/%m/%d %H:%M:%S") UNBAN [Email] = <F-USER> [IP] = <ip> unbanned." >> $LOG_FOLDER/3xipl-banned.log
|
||||||
|
|
||||||
|
[Init]
|
||||||
|
name = default
|
||||||
|
protocol = tcp
|
||||||
|
chain = INPUT
|
||||||
|
EOF
|
||||||
|
|
||||||
|
fail2ban-client -x start
|
||||||
|
fi
|
||||||
|
|
||||||
# Run x-ui
|
# Run x-ui
|
||||||
exec /app/x-ui
|
exec /app/x-ui
|
||||||
|
|
|
||||||
2
web/assets/css/custom.min.css
vendored
2
web/assets/css/custom.min.css
vendored
File diff suppressed because one or more lines are too long
|
|
@ -1763,12 +1763,13 @@ class Inbound extends XrayCommonClass {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vision seed applies only when vision flow is selected
|
// Vision seed applies only when the XTLS Vision (TCP/TLS) flow is selected.
|
||||||
|
// Excludes the UDP variant per spec.
|
||||||
canEnableVisionSeed() {
|
canEnableVisionSeed() {
|
||||||
if (!this.canEnableTlsFlow()) return false;
|
if (!this.canEnableTlsFlow()) return false;
|
||||||
const clients = this.settings?.vlesses;
|
const clients = this.settings?.vlesses;
|
||||||
if (!Array.isArray(clients)) return false;
|
if (!Array.isArray(clients)) return false;
|
||||||
return clients.some(c => c?.flow === TLS_FLOW_CONTROL.VISION || c?.flow === TLS_FLOW_CONTROL.VISION_UDP443);
|
return clients.some(c => c?.flow === TLS_FLOW_CONTROL.VISION);
|
||||||
}
|
}
|
||||||
|
|
||||||
canEnableReality() {
|
canEnableReality() {
|
||||||
|
|
@ -2542,15 +2543,13 @@ Inbound.VLESSSettings = class extends Inbound.Settings {
|
||||||
decryption = "none",
|
decryption = "none",
|
||||||
encryption = "none",
|
encryption = "none",
|
||||||
fallbacks = [],
|
fallbacks = [],
|
||||||
selectedAuth = undefined,
|
testseed = [],
|
||||||
testseed = [900, 500, 900, 256],
|
|
||||||
) {
|
) {
|
||||||
super(protocol);
|
super(protocol);
|
||||||
this.vlesses = vlesses;
|
this.vlesses = vlesses;
|
||||||
this.decryption = decryption;
|
this.decryption = decryption;
|
||||||
this.encryption = encryption;
|
this.encryption = encryption;
|
||||||
this.fallbacks = fallbacks;
|
this.fallbacks = fallbacks;
|
||||||
this.selectedAuth = selectedAuth;
|
|
||||||
this.testseed = testseed;
|
this.testseed = testseed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2562,12 +2561,23 @@ Inbound.VLESSSettings = class extends Inbound.Settings {
|
||||||
this.fallbacks.splice(index, 1);
|
this.fallbacks.splice(index, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Empty array means "use server defaults" (won't be sent).
|
||||||
|
// Anything else must be exactly 4 positive integers.
|
||||||
|
static isValidTestseed(arr) {
|
||||||
|
if (!Array.isArray(arr) || arr.length === 0) return true;
|
||||||
|
if (arr.length !== 4) return false;
|
||||||
|
return arr.every(v => Number.isInteger(v) && v > 0);
|
||||||
|
}
|
||||||
|
|
||||||
static fromJson(json = {}) {
|
static fromJson(json = {}) {
|
||||||
// Ensure testseed is always initialized as an array
|
// Preserve a saved testseed only if it's a valid 4-positive-int array; otherwise leave empty
|
||||||
let testseed = [900, 500, 900, 256];
|
// so toJson omits it and the form falls back to placeholder defaults.
|
||||||
if (json.testseed && Array.isArray(json.testseed) && json.testseed.length >= 4) {
|
const saved = json.testseed;
|
||||||
testseed = json.testseed;
|
const testseed = (Array.isArray(saved)
|
||||||
}
|
&& saved.length === 4
|
||||||
|
&& saved.every(v => Number.isInteger(v) && v > 0))
|
||||||
|
? saved
|
||||||
|
: [];
|
||||||
|
|
||||||
const obj = new Inbound.VLESSSettings(
|
const obj = new Inbound.VLESSSettings(
|
||||||
Protocols.VLESS,
|
Protocols.VLESS,
|
||||||
|
|
@ -2575,8 +2585,7 @@ Inbound.VLESSSettings = class extends Inbound.Settings {
|
||||||
json.decryption,
|
json.decryption,
|
||||||
json.encryption,
|
json.encryption,
|
||||||
Inbound.VLESSSettings.Fallback.fromJson(json.fallbacks || []),
|
Inbound.VLESSSettings.Fallback.fromJson(json.fallbacks || []),
|
||||||
json.selectedAuth,
|
testseed,
|
||||||
testseed
|
|
||||||
);
|
);
|
||||||
return obj;
|
return obj;
|
||||||
}
|
}
|
||||||
|
|
@ -2598,13 +2607,15 @@ Inbound.VLESSSettings = class extends Inbound.Settings {
|
||||||
if (this.fallbacks && this.fallbacks.length > 0) {
|
if (this.fallbacks && this.fallbacks.length > 0) {
|
||||||
json.fallbacks = Inbound.VLESSSettings.toJsonArray(this.fallbacks);
|
json.fallbacks = Inbound.VLESSSettings.toJsonArray(this.fallbacks);
|
||||||
}
|
}
|
||||||
if (this.selectedAuth) {
|
|
||||||
json.selectedAuth = this.selectedAuth;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only include testseed if at least one client has a flow set
|
// testseed is only meaningful for the exact xtls-rprx-vision flow, and only when
|
||||||
const hasFlow = this.vlesses && this.vlesses.some(vless => vless.flow && vless.flow !== '');
|
// the user supplied a complete 4-positive-int array. Otherwise omit and let the
|
||||||
if (hasFlow && this.testseed && this.testseed.length >= 4) {
|
// backend fall back to its safe defaults.
|
||||||
|
const hasVisionFlow = this.vlesses && this.vlesses.some(v => v.flow === TLS_FLOW_CONTROL.VISION);
|
||||||
|
if (hasVisionFlow
|
||||||
|
&& Array.isArray(this.testseed)
|
||||||
|
&& this.testseed.length === 4
|
||||||
|
&& this.testseed.every(v => Number.isInteger(v) && v > 0)) {
|
||||||
json.testseed = this.testseed;
|
json.testseed = this.testseed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1139,11 +1139,11 @@ class Outbound extends CommonClass {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vision seed applies only when vision flow is selected
|
// Vision seed applies only when the XTLS Vision (TCP/TLS) flow is selected.
|
||||||
|
// Excludes the UDP variant per spec.
|
||||||
canEnableVisionSeed() {
|
canEnableVisionSeed() {
|
||||||
if (!this.canEnableTlsFlow()) return false;
|
if (!this.canEnableTlsFlow()) return false;
|
||||||
const flow = this.settings?.flow;
|
return this.settings?.flow === TLS_FLOW_CONTROL.VISION;
|
||||||
return flow === TLS_FLOW_CONTROL.VISION || flow === TLS_FLOW_CONTROL.VISION_UDP443;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
canEnableReality() {
|
canEnableReality() {
|
||||||
|
|
@ -1799,7 +1799,7 @@ Outbound.VmessSettings = class extends CommonClass {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
Outbound.VLESSSettings = class extends CommonClass {
|
Outbound.VLESSSettings = class extends CommonClass {
|
||||||
constructor(address, port, id, flow, encryption, reverseTag = '', reverseSniffing = new ReverseSniffing(), testpre = 0, testseed = [900, 500, 900, 256]) {
|
constructor(address, port, id, flow, encryption, reverseTag = '', reverseSniffing = new ReverseSniffing(), testpre = 0, testseed = []) {
|
||||||
super();
|
super();
|
||||||
this.address = address;
|
this.address = address;
|
||||||
this.port = port;
|
this.port = port;
|
||||||
|
|
@ -1814,6 +1814,12 @@ Outbound.VLESSSettings = class extends CommonClass {
|
||||||
|
|
||||||
static fromJson(json = {}) {
|
static fromJson(json = {}) {
|
||||||
if (ObjectUtil.isEmpty(json.address) || ObjectUtil.isEmpty(json.port)) return new Outbound.VLESSSettings();
|
if (ObjectUtil.isEmpty(json.address) || ObjectUtil.isEmpty(json.port)) return new Outbound.VLESSSettings();
|
||||||
|
const saved = json.testseed;
|
||||||
|
const testseed = (Array.isArray(saved)
|
||||||
|
&& saved.length === 4
|
||||||
|
&& saved.every(v => Number.isInteger(v) && v > 0))
|
||||||
|
? saved
|
||||||
|
: [];
|
||||||
return new Outbound.VLESSSettings(
|
return new Outbound.VLESSSettings(
|
||||||
json.address,
|
json.address,
|
||||||
json.port,
|
json.port,
|
||||||
|
|
@ -1823,7 +1829,7 @@ Outbound.VLESSSettings = class extends CommonClass {
|
||||||
json.reverse?.tag || '',
|
json.reverse?.tag || '',
|
||||||
ReverseSniffing.fromJson(json.reverse?.sniffing || {}),
|
ReverseSniffing.fromJson(json.reverse?.sniffing || {}),
|
||||||
json.testpre || 0,
|
json.testpre || 0,
|
||||||
json.testseed && json.testseed.length >= 4 ? json.testseed : [900, 500, 900, 256]
|
testseed,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1843,12 +1849,14 @@ Outbound.VLESSSettings = class extends CommonClass {
|
||||||
sniffing: JSON.stringify(reverseSniffing) === JSON.stringify(defaultReverseSniffing) ? {} : reverseSniffing,
|
sniffing: JSON.stringify(reverseSniffing) === JSON.stringify(defaultReverseSniffing) ? {} : reverseSniffing,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// Only include Vision settings when flow is set
|
// Vision-specific knobs are only meaningful for the exact xtls-rprx-vision flow.
|
||||||
if (this.flow && this.flow !== '') {
|
if (this.flow === TLS_FLOW_CONTROL.VISION) {
|
||||||
if (this.testpre > 0) {
|
if (this.testpre > 0) {
|
||||||
result.testpre = this.testpre;
|
result.testpre = this.testpre;
|
||||||
}
|
}
|
||||||
if (this.testseed && this.testseed.length >= 4) {
|
if (Array.isArray(this.testseed)
|
||||||
|
&& this.testseed.length === 4
|
||||||
|
&& this.testseed.every(v => Number.isInteger(v) && v > 0)) {
|
||||||
result.testseed = this.testseed;
|
result.testseed = this.testseed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@
|
||||||
</a-tag>
|
</a-tag>
|
||||||
</template>
|
</template>
|
||||||
<template slot="client" slot-scope="text, client">
|
<template slot="client" slot-scope="text, client">
|
||||||
<a-space direction="horizontal" :size="2">
|
<a-space direction="horizontal" :size="2" style="flex-wrap:nowrap;min-width:0">
|
||||||
<a-tooltip>
|
<a-tooltip>
|
||||||
<template slot="title">
|
<template slot="title">
|
||||||
<template v-if="isClientDepleted(record, client.email)">{{ i18n "depleted" }}</template>
|
<template v-if="isClientDepleted(record, client.email)">{{ i18n "depleted" }}</template>
|
||||||
|
|
@ -73,8 +73,10 @@
|
||||||
<a-badge :class="isClientOnline(client.email)? 'online-animation' : ''"
|
<a-badge :class="isClientOnline(client.email)? 'online-animation' : ''"
|
||||||
:color="client.enable ? statsExpColor(record, client.email) : themeSwitcher.isDarkTheme ? '#2c3950' : '#bcbcbc'"></a-badge>
|
:color="client.enable ? statsExpColor(record, client.email) : themeSwitcher.isDarkTheme ? '#2c3950' : '#bcbcbc'"></a-badge>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
<a-space direction="vertical" :size="2">
|
<a-space direction="vertical" :size="2" style="min-width:0;overflow:hidden">
|
||||||
<span class="client-email">[[ client.email ]]</span>
|
<a-tooltip :title="client.email" :overlay-class-name="themeSwitcher.currentTheme">
|
||||||
|
<span class="client-email">[[ client.email ]]</span>
|
||||||
|
</a-tooltip>
|
||||||
<template v-if="client.comment && client.comment.trim()">
|
<template v-if="client.comment && client.comment.trim()">
|
||||||
<a-tooltip v-if="client.comment.length > 50" :overlay-class-name="themeSwitcher.currentTheme">
|
<a-tooltip v-if="client.comment.length > 50" :overlay-class-name="themeSwitcher.currentTheme">
|
||||||
<template slot="title">
|
<template slot="title">
|
||||||
|
|
@ -102,7 +104,7 @@
|
||||||
</table>
|
</table>
|
||||||
</template>
|
</template>
|
||||||
<div class="tr-table-box">
|
<div class="tr-table-box">
|
||||||
<div class="tr-table-lt">[[ SizeFormatter.sizeFormat(getSumStats(record, client.email)) ]]</div>
|
<div class="tr-table-rt">[[ SizeFormatter.sizeFormat(getSumStats(record, client.email)) ]]</div>
|
||||||
<div class="tr-table-bar" v-if="!client.enable">
|
<div class="tr-table-bar" v-if="!client.enable">
|
||||||
<a-progress :stroke-color="themeSwitcher.isDarkTheme ? 'rgb(72 84 105)' : '#bcbcbc'" :show-info="false" :percent="statsProgress(record, client.email)" />
|
<a-progress :stroke-color="themeSwitcher.isDarkTheme ? 'rgb(72 84 105)' : '#bcbcbc'" :show-info="false" :percent="statsProgress(record, client.email)" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -207,20 +209,20 @@
|
||||||
</a-dropdown>
|
</a-dropdown>
|
||||||
</template>
|
</template>
|
||||||
<template slot="info" slot-scope="text, client, index">
|
<template slot="info" slot-scope="text, client, index">
|
||||||
<a-popover placement="bottomRight" :overlay-class-name="themeSwitcher.currentTheme" trigger="click">
|
<a-popover :placement="isMobile ? 'bottomLeft' : 'bottomRight'" :overlay-class-name="themeSwitcher.currentTheme" trigger="click">
|
||||||
<template slot="content">
|
<template slot="content">
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="3" :style="{ textAlign: 'center' }">{{ i18n "pages.inbounds.traffic" }}</td>
|
<td colspan="3" :style="{ textAlign: 'center' }">{{ i18n "pages.inbounds.traffic" }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="80px" :style="{ margin: '0', textAlign: 'right', fontSize: '1em' }"> [[
|
<td width="65px" :style="{ margin: '0', textAlign: 'right', fontSize: '1em' }"> [[
|
||||||
SizeFormatter.sizeFormat(getUpStats(record, client.email) + getDownStats(record, client.email)) ]] </td>
|
SizeFormatter.sizeFormat(getUpStats(record, client.email) + getDownStats(record, client.email)) ]] </td>
|
||||||
<td width="120px" v-if="!client.enable">
|
<td width="90px" v-if="!client.enable">
|
||||||
<a-progress :stroke-color="themeSwitcher.isDarkTheme ? 'rgb(72 84 105)' : '#bcbcbc'" :show-info="false"
|
<a-progress :stroke-color="themeSwitcher.isDarkTheme ? 'rgb(72 84 105)' : '#bcbcbc'" :show-info="false"
|
||||||
:percent="statsProgress(record, client.email)" />
|
:percent="statsProgress(record, client.email)" />
|
||||||
</td>
|
</td>
|
||||||
<td width="120px" v-else-if="client.totalGB > 0">
|
<td width="90px" v-else-if="client.totalGB > 0">
|
||||||
<a-popover :overlay-class-name="themeSwitcher.currentTheme">
|
<a-popover :overlay-class-name="themeSwitcher.currentTheme">
|
||||||
<template slot="content" v-if="client.email">
|
<template slot="content" v-if="client.email">
|
||||||
<table cellpadding="2" width="100%">
|
<table cellpadding="2" width="100%">
|
||||||
|
|
@ -239,11 +241,11 @@
|
||||||
:percent="statsProgress(record, client.email)" />
|
:percent="statsProgress(record, client.email)" />
|
||||||
</a-popover>
|
</a-popover>
|
||||||
</td>
|
</td>
|
||||||
<td width="120px" v-else class="infinite-bar">
|
<td width="90px" v-else class="infinite-bar">
|
||||||
<a-progress :stroke-color="themeSwitcher.isDarkTheme ? '#2c1e32':'#F2EAF1'" :show-info="false"
|
<a-progress :stroke-color="themeSwitcher.isDarkTheme ? '#2c1e32':'#F2EAF1'" :show-info="false"
|
||||||
:percent="100"></a-progress>
|
:percent="100"></a-progress>
|
||||||
</td>
|
</td>
|
||||||
<td width="80px">
|
<td width="60px">
|
||||||
<template v-if="client.totalGB > 0">[[ client._totalGB + "GB" ]]</template>
|
<template v-if="client.totalGB > 0">[[ client._totalGB + "GB" ]]</template>
|
||||||
<span v-else class="tr-infinity-ch">∞</span>
|
<span v-else class="tr-infinity-ch">∞</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -256,9 +258,9 @@
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<template v-if="client.expiryTime !=0 && client.reset >0">
|
<template v-if="client.expiryTime !=0 && client.reset >0">
|
||||||
<td width="80px" :style="{ margin: '0', textAlign: 'right', fontSize: '1em' }"> [[
|
<td width="65px" :style="{ margin: '0', textAlign: 'right', fontSize: '1em' }"> [[
|
||||||
IntlUtil.formatRelativeTime(client.expiryTime) ]] </td>
|
IntlUtil.formatRelativeTime(client.expiryTime) ]] </td>
|
||||||
<td width="120px" class="infinite-bar">
|
<td width="90px" class="infinite-bar">
|
||||||
<a-popover :overlay-class-name="themeSwitcher.currentTheme">
|
<a-popover :overlay-class-name="themeSwitcher.currentTheme">
|
||||||
<template slot="content">
|
<template slot="content">
|
||||||
<span v-if="client.expiryTime < 0">{{ i18n "pages.client.delayedStart" }}</span>
|
<span v-if="client.expiryTime < 0">{{ i18n "pages.client.delayedStart" }}</span>
|
||||||
|
|
@ -268,7 +270,7 @@
|
||||||
:percent="expireProgress(client.expiryTime, client.reset)" />
|
:percent="expireProgress(client.expiryTime, client.reset)" />
|
||||||
</a-popover>
|
</a-popover>
|
||||||
</td>
|
</td>
|
||||||
<td width="60px">[[ client.reset + "d" ]]</td>
|
<td width="50px">[[ client.reset + "d" ]]</td>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<td colspan="3" :style="{ textAlign: 'center' }">
|
<td colspan="3" :style="{ textAlign: 'center' }">
|
||||||
|
|
|
||||||
|
|
@ -21,16 +21,6 @@
|
||||||
</a-collapse>
|
</a-collapse>
|
||||||
<template v-if=" !inbound.stream.isTLS || !inbound.stream.isReality">
|
<template v-if=" !inbound.stream.isTLS || !inbound.stream.isReality">
|
||||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||||
<a-form-item label="Authentication">
|
|
||||||
<a-select v-model="inbound.settings.selectedAuth" @change="getNewVlessEnc"
|
|
||||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
|
||||||
<a-select-option :value="undefined">None</a-select-option>
|
|
||||||
<a-select-option value="X25519, not Post-Quantum">X25519 (not
|
|
||||||
Post-Quantum)</a-select-option>
|
|
||||||
<a-select-option value="ML-KEM-768, Post-Quantum">ML-KEM-768
|
|
||||||
(Post-Quantum)</a-select-option>
|
|
||||||
</a-select>
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label="decryption">
|
<a-form-item label="decryption">
|
||||||
<a-input v-model.trim="inbound.settings.decryption"></a-input>
|
<a-input v-model.trim="inbound.settings.decryption"></a-input>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
@ -38,16 +28,20 @@
|
||||||
<a-input v-model="inbound.settings.encryption"></a-input>
|
<a-input v-model="inbound.settings.encryption"></a-input>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label=" ">
|
<a-form-item label=" ">
|
||||||
<a-space>
|
<a-space :size="8" wrap>
|
||||||
<a-button type="primary" icon="import" @click="getNewVlessEnc">Get New
|
<a-button type="primary" icon="import" @click="getNewVlessEnc('X25519, not Post-Quantum')">
|
||||||
keys</a-button>
|
X25519
|
||||||
|
</a-button>
|
||||||
|
<a-button type="primary" icon="import" @click="getNewVlessEnc('ML-KEM-768, Post-Quantum')">
|
||||||
|
ML-KEM-768
|
||||||
|
</a-button>
|
||||||
<a-button danger @click="clearVlessEnc">Clear</a-button>
|
<a-button danger @click="clearVlessEnc">Clear</a-button>
|
||||||
</a-space>
|
</a-space>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-form>
|
</a-form>
|
||||||
<a-divider :style="{ margin: '5px 0' }"></a-divider>
|
<a-divider :style="{ margin: '5px 0' }"></a-divider>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="inbound.isTcp && !inbound.settings.selectedAuth">
|
<template v-if="inbound.isTcp && (!inbound.settings.encryption || inbound.settings.encryption === 'none')">
|
||||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||||
<a-form-item label="Fallbacks">
|
<a-form-item label="Fallbacks">
|
||||||
<a-button icon="plus" type="primary" size="small" @click="inbound.settings.addFallback()"></a-button>
|
<a-button icon="plus" type="primary" size="small" @click="inbound.settings.addFallback()"></a-button>
|
||||||
|
|
@ -81,30 +75,38 @@
|
||||||
</template>
|
</template>
|
||||||
<template v-if="inbound.canEnableVisionSeed()">
|
<template v-if="inbound.canEnableVisionSeed()">
|
||||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||||
<a-form-item label="Vision Seed">
|
<a-form-item
|
||||||
|
:validate-status="testseedError() ? 'error' : ''"
|
||||||
|
:help="testseedError() || ''">
|
||||||
|
<template slot="label">
|
||||||
|
Vision Seed
|
||||||
|
<a-tooltip title="Optional. Controls XTLS Vision padding. Provide exactly 4 positive integers, or leave empty to use defaults: [900, 500, 900, 256].">
|
||||||
|
<a-icon type="question-circle" :style="{ marginLeft: '4px' }"></a-icon>
|
||||||
|
</a-tooltip>
|
||||||
|
</template>
|
||||||
<a-row :gutter="8">
|
<a-row :gutter="8">
|
||||||
<a-col :span="6">
|
<a-col :span="6">
|
||||||
<a-input-number
|
<a-input-number
|
||||||
:value="(inbound.settings.testseed && inbound.settings.testseed[0] !== undefined) ? inbound.settings.testseed[0] : 900"
|
:value="(inbound.settings.testseed && inbound.settings.testseed[0] !== undefined) ? inbound.settings.testseed[0] : null"
|
||||||
@change="(val) => updateTestseed(0, val)" :min="0" :max="9999" :style="{ width: '100%' }" placeholder="900"
|
@change="(val) => updateTestseed(0, val)" :min="1" :max="9999" :style="{ width: '100%' }" placeholder="900"
|
||||||
addon-before="[0]"></a-input-number>
|
addon-before="[0]"></a-input-number>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :span="6">
|
<a-col :span="6">
|
||||||
<a-input-number
|
<a-input-number
|
||||||
:value="(inbound.settings.testseed && inbound.settings.testseed[1] !== undefined) ? inbound.settings.testseed[1] : 500"
|
:value="(inbound.settings.testseed && inbound.settings.testseed[1] !== undefined) ? inbound.settings.testseed[1] : null"
|
||||||
@change="(val) => updateTestseed(1, val)" :min="0" :max="9999" :style="{ width: '100%' }" placeholder="500"
|
@change="(val) => updateTestseed(1, val)" :min="1" :max="9999" :style="{ width: '100%' }" placeholder="500"
|
||||||
addon-before="[1]"></a-input-number>
|
addon-before="[1]"></a-input-number>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :span="6">
|
<a-col :span="6">
|
||||||
<a-input-number
|
<a-input-number
|
||||||
:value="(inbound.settings.testseed && inbound.settings.testseed[2] !== undefined) ? inbound.settings.testseed[2] : 900"
|
:value="(inbound.settings.testseed && inbound.settings.testseed[2] !== undefined) ? inbound.settings.testseed[2] : null"
|
||||||
@change="(val) => updateTestseed(2, val)" :min="0" :max="9999" :style="{ width: '100%' }" placeholder="900"
|
@change="(val) => updateTestseed(2, val)" :min="1" :max="9999" :style="{ width: '100%' }" placeholder="900"
|
||||||
addon-before="[2]"></a-input-number>
|
addon-before="[2]"></a-input-number>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :span="6">
|
<a-col :span="6">
|
||||||
<a-input-number
|
<a-input-number
|
||||||
:value="(inbound.settings.testseed && inbound.settings.testseed[3] !== undefined) ? inbound.settings.testseed[3] : 256"
|
:value="(inbound.settings.testseed && inbound.settings.testseed[3] !== undefined) ? inbound.settings.testseed[3] : null"
|
||||||
@change="(val) => updateTestseed(3, val)" :min="0" :max="9999" :style="{ width: '100%' }" placeholder="256"
|
@change="(val) => updateTestseed(3, val)" :min="1" :max="9999" :style="{ width: '100%' }" placeholder="256"
|
||||||
addon-before="[3]"></a-input-number>
|
addon-before="[3]"></a-input-number>
|
||||||
</a-col>
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
|
|
@ -116,6 +118,10 @@
|
||||||
Reset
|
Reset
|
||||||
</a-button>
|
</a-button>
|
||||||
</a-space>
|
</a-space>
|
||||||
|
<div :style="{ marginTop: '6px', fontSize: '12px', color: 'inherit', opacity: 0.65, lineHeight: 1.4 }">
|
||||||
|
Optional. Controls XTLS Vision padding behavior (used only for xtls-rprx-vision).
|
||||||
|
Provide exactly four positive integers to customize padding; otherwise leave empty to use safe defaults.
|
||||||
|
</div>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-form>
|
</a-form>
|
||||||
<a-divider :style="{ margin: '5px 0' }"></a-divider>
|
<a-divider :style="{ margin: '5px 0' }"></a-divider>
|
||||||
|
|
|
||||||
|
|
@ -182,6 +182,7 @@
|
||||||
<a-input v-if="!enableFilter" v-model.lazy="searchKey" placeholder='{{ i18n "search" }}' autofocus
|
<a-input v-if="!enableFilter" v-model.lazy="searchKey" placeholder='{{ i18n "search" }}' autofocus
|
||||||
:style="{ maxWidth: '300px' }" :size="isMobile ? 'small' : ''"></a-input>
|
:style="{ maxWidth: '300px' }" :size="isMobile ? 'small' : ''"></a-input>
|
||||||
<a-radio-group v-if="enableFilter" v-model="filterBy" @change="filterInbounds" button-style="solid"
|
<a-radio-group v-if="enableFilter" v-model="filterBy" @change="filterInbounds" button-style="solid"
|
||||||
|
class="mobile-filter-group"
|
||||||
:size="isMobile ? 'small' : ''">
|
:size="isMobile ? 'small' : ''">
|
||||||
<a-radio-button value>{{ i18n "none" }}</a-radio-button>
|
<a-radio-button value>{{ i18n "none" }}</a-radio-button>
|
||||||
<a-radio-button value="active">{{ i18n "subscription.active"
|
<a-radio-button value="active">{{ i18n "subscription.active"
|
||||||
|
|
@ -2331,6 +2332,31 @@
|
||||||
#content-layout>.ant-layout-content>.ant-spin-nested-loading>div>.ant-spin {
|
#content-layout>.ant-layout-content>.ant-spin-nested-loading>div>.ant-spin {
|
||||||
left: 50vw !important;
|
left: 50vw !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Keep filter choices in a single horizontal line on phones. */
|
||||||
|
.inbounds-page .mobile-filter-group {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
.inbounds-page .mobile-filter-group .ant-radio-button-wrapper {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prevent mobile row content from splitting across multiple lines. */
|
||||||
|
.inbounds-page .ant-table-tbody > tr > td {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.inbounds-page .ant-table-tbody > tr > td:nth-child(3) {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Protocol cell — wrap tags into a flex grid with consistent gap so
|
/* Protocol cell — wrap tags into a flex grid with consistent gap so
|
||||||
|
|
|
||||||
|
|
@ -90,14 +90,9 @@
|
||||||
<a-tag :color="inbound.stream.security == 'none' ? 'red' : 'green'">[[
|
<a-tag :color="inbound.stream.security == 'none' ? 'red' : 'green'">[[
|
||||||
inbound.stream.security ]]</a-tag>
|
inbound.stream.security ]]</a-tag>
|
||||||
<br />
|
<br />
|
||||||
<td>Authentication</td>
|
|
||||||
<a-tag v-if="inbound.settings.selectedAuth" color="green">[[
|
|
||||||
inbound.settings.selectedAuth ? inbound.settings.selectedAuth : ''
|
|
||||||
]]</a-tag>
|
|
||||||
<a-tag v-else color="red">{{ i18n "none" }}</a-tag>
|
|
||||||
<br />
|
|
||||||
{{ i18n "encryption" }}
|
{{ i18n "encryption" }}
|
||||||
<a-tag class="info-large-tag" :color="inbound.settings.encryption ? 'green' : 'red'">[[
|
<a-tag class="info-large-tag"
|
||||||
|
:color="inbound.settings.encryption && inbound.settings.encryption !== 'none' ? 'green' : 'red'">[[
|
||||||
inbound.settings.encryption ? inbound.settings.encryption : ''
|
inbound.settings.encryption ? inbound.settings.encryption : ''
|
||||||
]]</a-tag>
|
]]</a-tag>
|
||||||
<a-tooltip title='{{ i18n "copy" }}'>
|
<a-tooltip title='{{ i18n "copy" }}'>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,16 @@
|
||||||
inbound: new Inbound(),
|
inbound: new Inbound(),
|
||||||
dbInbound: new DBInbound(),
|
dbInbound: new DBInbound(),
|
||||||
ok() {
|
ok() {
|
||||||
|
// Block submit when Vision Seed is XRV-gated and partially/invalidly filled.
|
||||||
|
const seedErr = inModal.testseedError();
|
||||||
|
if (seedErr) {
|
||||||
|
if (typeof Vue !== "undefined" && Vue.prototype && Vue.prototype.$message) {
|
||||||
|
Vue.prototype.$message.error(seedErr);
|
||||||
|
} else {
|
||||||
|
alert(seedErr);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
ObjectUtil.execute(inModal.confirm, inModal.inbound, inModal.dbInbound);
|
ObjectUtil.execute(inModal.confirm, inModal.inbound, inModal.dbInbound);
|
||||||
},
|
},
|
||||||
show({
|
show({
|
||||||
|
|
@ -33,16 +43,12 @@
|
||||||
} else {
|
} else {
|
||||||
this.inbound = new Inbound();
|
this.inbound = new Inbound();
|
||||||
}
|
}
|
||||||
// Always ensure testseed is initialized for VLESS protocol (even if vision flow is not set yet)
|
// Ensure VLESS settings has a testseed array reference for Vue reactivity,
|
||||||
// This ensures Vue reactivity works properly
|
// but leave it empty so we don't auto-emit defaults — user must explicitly
|
||||||
|
// fill all four fields, or leave blank to fall back to backend defaults.
|
||||||
if (this.inbound.protocol === Protocols.VLESS && this.inbound.settings) {
|
if (this.inbound.protocol === Protocols.VLESS && this.inbound.settings) {
|
||||||
if (
|
if (!Array.isArray(this.inbound.settings.testseed)) {
|
||||||
!this.inbound.settings.testseed ||
|
this.inbound.settings.testseed = [];
|
||||||
!Array.isArray(this.inbound.settings.testseed) ||
|
|
||||||
this.inbound.settings.testseed.length < 4
|
|
||||||
) {
|
|
||||||
// Create a new array to ensure Vue reactivity
|
|
||||||
this.inbound.settings.testseed = [900, 500, 900, 256].slice();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (dbInbound) {
|
if (dbInbound) {
|
||||||
|
|
@ -61,48 +67,50 @@
|
||||||
loading(loading = true) {
|
loading(loading = true) {
|
||||||
inModal.confirmLoading = loading;
|
inModal.confirmLoading = loading;
|
||||||
},
|
},
|
||||||
// Vision Seed methods - always available regardless of Vue context
|
// Returns an error string when the current testseed state would be rejected,
|
||||||
|
// or "" when it's valid (empty == use defaults; full 4 positive ints == custom).
|
||||||
|
testseedError() {
|
||||||
|
if (!inModal.inbound || inModal.inbound.protocol !== Protocols.VLESS) return "";
|
||||||
|
if (typeof inModal.inbound.canEnableVisionSeed === "function"
|
||||||
|
&& !inModal.inbound.canEnableVisionSeed()) return "";
|
||||||
|
const seed = inModal.inbound.settings && inModal.inbound.settings.testseed;
|
||||||
|
if (!Array.isArray(seed) || seed.length === 0) return "";
|
||||||
|
const filled = seed.filter(v => v !== null && v !== undefined && v !== "");
|
||||||
|
if (filled.length === 0) return "";
|
||||||
|
if (seed.length !== 4 || filled.length !== 4 ||
|
||||||
|
!seed.every(v => Number.isInteger(v) && v > 0)) {
|
||||||
|
return "Provide exactly 4 positive integers or leave empty to use defaults.";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
},
|
||||||
|
// Vision Seed helpers — always available regardless of Vue context
|
||||||
updateTestseed(index, value) {
|
updateTestseed(index, value) {
|
||||||
// Use inModal.inbound explicitly to ensure correct context
|
|
||||||
if (!inModal.inbound || !inModal.inbound.settings) return;
|
if (!inModal.inbound || !inModal.inbound.settings) return;
|
||||||
// Ensure testseed is initialized
|
if (!Array.isArray(inModal.inbound.settings.testseed)) {
|
||||||
if (
|
inModal.inbound.settings.testseed = [];
|
||||||
!inModal.inbound.settings.testseed ||
|
|
||||||
!Array.isArray(inModal.inbound.settings.testseed)
|
|
||||||
) {
|
|
||||||
inModal.inbound.settings.testseed = [900, 500, 900, 256];
|
|
||||||
}
|
}
|
||||||
// Ensure array has enough elements
|
const seed = inModal.inbound.settings.testseed;
|
||||||
while (inModal.inbound.settings.testseed.length <= index) {
|
while (seed.length <= index) seed.push(null);
|
||||||
inModal.inbound.settings.testseed.push(0);
|
seed[index] = value;
|
||||||
|
// If user cleared every slot, collapse back to empty so we omit testseed entirely.
|
||||||
|
if (seed.every(v => v === null || v === undefined || v === "")) {
|
||||||
|
inModal.inbound.settings.testseed = [];
|
||||||
}
|
}
|
||||||
// Update value
|
|
||||||
inModal.inbound.settings.testseed[index] = value;
|
|
||||||
},
|
},
|
||||||
setRandomTestseed() {
|
setRandomTestseed() {
|
||||||
// Use inModal.inbound explicitly to ensure correct context
|
|
||||||
if (!inModal.inbound || !inModal.inbound.settings) return;
|
if (!inModal.inbound || !inModal.inbound.settings) return;
|
||||||
// Ensure testseed is initialized
|
// Positive integers only (>=1) so the array passes validation and gets emitted.
|
||||||
if (
|
|
||||||
!inModal.inbound.settings.testseed ||
|
|
||||||
!Array.isArray(inModal.inbound.settings.testseed) ||
|
|
||||||
inModal.inbound.settings.testseed.length < 4
|
|
||||||
) {
|
|
||||||
inModal.inbound.settings.testseed = [900, 500, 900, 256].slice();
|
|
||||||
}
|
|
||||||
// Create new array with random values
|
|
||||||
inModal.inbound.settings.testseed = [
|
inModal.inbound.settings.testseed = [
|
||||||
Math.floor(Math.random() * 1000),
|
Math.floor(Math.random() * 999) + 1,
|
||||||
Math.floor(Math.random() * 1000),
|
Math.floor(Math.random() * 999) + 1,
|
||||||
Math.floor(Math.random() * 1000),
|
Math.floor(Math.random() * 999) + 1,
|
||||||
Math.floor(Math.random() * 1000),
|
Math.floor(Math.random() * 999) + 1,
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
resetTestseed() {
|
resetTestseed() {
|
||||||
// Use inModal.inbound explicitly to ensure correct context
|
|
||||||
if (!inModal.inbound || !inModal.inbound.settings) return;
|
if (!inModal.inbound || !inModal.inbound.settings) return;
|
||||||
// Reset testseed to default values
|
// Empty == "use server defaults [900, 500, 900, 256]"; placeholders show in the form.
|
||||||
inModal.inbound.settings.testseed = [900, 500, 900, 256].slice();
|
inModal.inbound.settings.testseed = [];
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -170,27 +178,17 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Ensure testseed is always initialized when vision flow is enabled
|
// Keep testseed as a valid array reference for Vue reactivity while the user
|
||||||
|
// toggles flows — but do NOT auto-fill defaults. Empty means "use server defaults"
|
||||||
|
// and is the only way the form omits testseed from the outbound JSON.
|
||||||
"inModal.inbound.settings.vlesses": {
|
"inModal.inbound.settings.vlesses": {
|
||||||
handler() {
|
handler() {
|
||||||
if (
|
if (
|
||||||
inModal.inbound.protocol === Protocols.VLESS &&
|
inModal.inbound.protocol === Protocols.VLESS &&
|
||||||
inModal.inbound.settings &&
|
inModal.inbound.settings &&
|
||||||
inModal.inbound.settings.vlesses
|
!Array.isArray(inModal.inbound.settings.testseed)
|
||||||
) {
|
) {
|
||||||
const hasVisionFlow = inModal.inbound.settings.vlesses.some(
|
inModal.inbound.settings.testseed = [];
|
||||||
(c) =>
|
|
||||||
c.flow === "xtls-rprx-vision" ||
|
|
||||||
c.flow === "xtls-rprx-vision-udp443",
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
hasVisionFlow &&
|
|
||||||
(!inModal.inbound.settings.testseed ||
|
|
||||||
!Array.isArray(inModal.inbound.settings.testseed) ||
|
|
||||||
inModal.inbound.settings.testseed.length < 4)
|
|
||||||
) {
|
|
||||||
inModal.inbound.settings.testseed = [900, 500, 900, 256];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
deep: true,
|
deep: true,
|
||||||
|
|
@ -304,12 +302,11 @@
|
||||||
this.inbound.stream.tls.echServerKeys = "";
|
this.inbound.stream.tls.echServerKeys = "";
|
||||||
this.inbound.stream.tls.settings.echConfigList = "";
|
this.inbound.stream.tls.settings.echConfigList = "";
|
||||||
},
|
},
|
||||||
async getNewVlessEnc() {
|
// Pulls the requested auth block from `xray vlessenc` (which always returns
|
||||||
const selected = inModal.inbound.settings.selectedAuth;
|
// both X25519 and ML-KEM-768 variants) and applies it to the inbound's
|
||||||
if (!selected) {
|
// decryption/encryption strings. The auth mode is implied by the resulting
|
||||||
this.clearVlessEnc();
|
async getNewVlessEnc(authLabel) {
|
||||||
return;
|
if (!authLabel) return;
|
||||||
}
|
|
||||||
|
|
||||||
inModal.loading(true);
|
inModal.loading(true);
|
||||||
const msg = await HttpUtil.get("/panel/api/server/getNewVlessEnc");
|
const msg = await HttpUtil.get("/panel/api/server/getNewVlessEnc");
|
||||||
|
|
@ -320,10 +317,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const auths = msg.obj.auths || [];
|
const auths = msg.obj.auths || [];
|
||||||
const block = auths.find((a) => a.label === selected);
|
const block = auths.find((a) => a.label === authLabel);
|
||||||
|
|
||||||
if (!block) {
|
if (!block) {
|
||||||
console.error("No auth block for", selected);
|
console.error("No auth block for", authLabel);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -333,37 +330,37 @@
|
||||||
clearVlessEnc() {
|
clearVlessEnc() {
|
||||||
this.inbound.settings.decryption = "none";
|
this.inbound.settings.decryption = "none";
|
||||||
this.inbound.settings.encryption = "none";
|
this.inbound.settings.encryption = "none";
|
||||||
this.inbound.settings.selectedAuth = undefined;
|
|
||||||
},
|
},
|
||||||
// Vision Seed methods - must be in Vue methods for proper binding
|
// Vision Seed methods - must be in Vue methods for proper template binding.
|
||||||
|
// Mirror the inModal helpers but use Vue.set so the form re-renders.
|
||||||
updateTestseed(index, value) {
|
updateTestseed(index, value) {
|
||||||
// Ensure testseed is initialized
|
if (!Array.isArray(this.inbound.settings.testseed)) {
|
||||||
if (
|
this.$set(this.inbound.settings, "testseed", []);
|
||||||
!this.inbound.settings.testseed ||
|
|
||||||
!Array.isArray(this.inbound.settings.testseed)
|
|
||||||
) {
|
|
||||||
this.$set(this.inbound.settings, "testseed", [900, 500, 900, 256]);
|
|
||||||
}
|
}
|
||||||
// Ensure array has enough elements
|
const seed = this.inbound.settings.testseed;
|
||||||
while (this.inbound.settings.testseed.length <= index) {
|
while (seed.length <= index) seed.push(null);
|
||||||
this.inbound.settings.testseed.push(0);
|
this.$set(seed, index, value);
|
||||||
|
// Collapse to empty when every slot is cleared so testseed is omitted from JSON.
|
||||||
|
if (seed.every(v => v === null || v === undefined || v === "")) {
|
||||||
|
this.$set(this.inbound.settings, "testseed", []);
|
||||||
}
|
}
|
||||||
// Update value using Vue.set for reactivity
|
|
||||||
this.$set(this.inbound.settings.testseed, index, value);
|
|
||||||
},
|
},
|
||||||
setRandomTestseed() {
|
setRandomTestseed() {
|
||||||
// Create new array with random values and use Vue.set for reactivity
|
// Positive integers only (>=1) so the resulting array passes validation.
|
||||||
const newSeed = [
|
const newSeed = [
|
||||||
Math.floor(Math.random() * 1000),
|
Math.floor(Math.random() * 999) + 1,
|
||||||
Math.floor(Math.random() * 1000),
|
Math.floor(Math.random() * 999) + 1,
|
||||||
Math.floor(Math.random() * 1000),
|
Math.floor(Math.random() * 999) + 1,
|
||||||
Math.floor(Math.random() * 1000),
|
Math.floor(Math.random() * 999) + 1,
|
||||||
];
|
];
|
||||||
this.$set(this.inbound.settings, "testseed", newSeed);
|
this.$set(this.inbound.settings, "testseed", newSeed);
|
||||||
},
|
},
|
||||||
resetTestseed() {
|
resetTestseed() {
|
||||||
// Reset testseed to default values using Vue.set for reactivity
|
// Empty == "use server defaults [900, 500, 900, 256]". Placeholders will show in the form.
|
||||||
this.$set(this.inbound.settings, "testseed", [900, 500, 900, 256]);
|
this.$set(this.inbound.settings, "testseed", []);
|
||||||
|
},
|
||||||
|
testseedError() {
|
||||||
|
return inModal.testseedError();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,7 @@
|
||||||
<a-col :xs="24" :sm="14" :lg="14">
|
<a-col :xs="24" :sm="14" :lg="14">
|
||||||
<a-space direction="horizontal" size="small" class="outbounds-toolbar">
|
<a-space direction="horizontal" size="small" class="outbounds-toolbar">
|
||||||
<a-button type="primary" icon="plus" @click="addOutbound">
|
<a-button type="primary" icon="plus" @click="addOutbound">
|
||||||
<span v-if="!isMobile">{{ i18n
|
<span v-if="!isMobile">{{ i18n "pages.xray.outbound.addOutbound" }}</span>
|
||||||
"pages.xray.outbound.addOutbound" }}</span>
|
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-button type="primary" icon="cloud" @click="showWarp()">WARP</a-button>
|
<a-button type="primary" icon="cloud" @click="showWarp()">WARP</a-button>
|
||||||
<a-button type="primary" icon="api" @click="showNord()">NordVPN</a-button>
|
<a-button type="primary" icon="api" @click="showNord()">NordVPN</a-button>
|
||||||
|
|
@ -25,9 +24,114 @@
|
||||||
</a-button-group>
|
</a-button-group>
|
||||||
</a-col>
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
<a-table :columns="outboundColumns" :row-key="r => r.key"
|
|
||||||
|
<!-- Mobile: card list -->
|
||||||
|
<template v-if="isMobile">
|
||||||
|
<div v-if="outboundData.length === 0" class="outbound-card-empty">—</div>
|
||||||
|
<div v-for="(outbound, index) in outboundData" :key="outbound.key" class="outbound-card">
|
||||||
|
<!-- card header: number + tag + protocol pills + action menu -->
|
||||||
|
<div class="outbound-card-header">
|
||||||
|
<div class="outbound-card-identity">
|
||||||
|
<div class="outbound-card-title">
|
||||||
|
<span class="outbound-card-num">[[ index + 1 ]]</span>
|
||||||
|
<a-tooltip :title="outbound.tag" :overlay-class-name="themeSwitcher.currentTheme">
|
||||||
|
<span class="outbound-tag">[[ outbound.tag ]]</span>
|
||||||
|
</a-tooltip>
|
||||||
|
</div>
|
||||||
|
<div class="outbound-protocol-cell">
|
||||||
|
<span class="outbound-pill" :class="outboundProtocolTone(outbound.protocol)">
|
||||||
|
[[ outbound.protocol ]]
|
||||||
|
</span>
|
||||||
|
<template v-if="[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(outbound.protocol)">
|
||||||
|
<span class="outbound-pill" :class="outboundNetworkTone(outbound.streamSettings.network)">
|
||||||
|
[[ outbound.streamSettings.network ]]
|
||||||
|
</span>
|
||||||
|
<span class="outbound-pill" :class="outboundSecurityTone(outbound.streamSettings.security)"
|
||||||
|
v-if="isOutboundSecurityVisible(outbound.streamSettings.security)">
|
||||||
|
[[ outbound.streamSettings.security ]]
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a-dropdown :trigger="['click']">
|
||||||
|
<a-button shape="circle" size="small" class="outbound-action-btn"
|
||||||
|
@click="e => e.preventDefault()">
|
||||||
|
<a-icon type="more"></a-icon>
|
||||||
|
</a-button>
|
||||||
|
<a-menu slot="overlay" :theme="themeSwitcher.currentTheme">
|
||||||
|
<a-menu-item v-if="index > 0" @click="setFirstOutbound(index)">
|
||||||
|
<a-icon type="vertical-align-top"></a-icon>
|
||||||
|
<span>{{ i18n "pages.xray.rules.first" }}</span>
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item @click="editOutbound(index)">
|
||||||
|
<a-icon type="edit"></a-icon>
|
||||||
|
<span>{{ i18n "edit" }}</span>
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item @click="resetOutboundTraffic(index)">
|
||||||
|
<a-icon type="retweet"></a-icon>
|
||||||
|
<span>{{ i18n "pages.inbounds.resetTraffic" }}</span>
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item @click="deleteOutbound(index)">
|
||||||
|
<span :style="{ color: '#FF4D4F' }">
|
||||||
|
<a-icon type="delete"></a-icon>
|
||||||
|
<span>{{ i18n "delete" }}</span>
|
||||||
|
</span>
|
||||||
|
</a-menu-item>
|
||||||
|
</a-menu>
|
||||||
|
</a-dropdown>
|
||||||
|
</div>
|
||||||
|
<!-- address pills -->
|
||||||
|
<div class="outbound-address-list" v-if="outboundAddresses(outbound).length > 0">
|
||||||
|
<a-tooltip v-for="addr in outboundAddresses(outbound)" :key="addr"
|
||||||
|
:title="addr" :overlay-class-name="themeSwitcher.currentTheme">
|
||||||
|
<span class="outbound-address-pill">[[ addr ]]</span>
|
||||||
|
</a-tooltip>
|
||||||
|
</div>
|
||||||
|
<!-- card footer: traffic + test -->
|
||||||
|
<div class="outbound-card-footer">
|
||||||
|
<div class="outbound-traffic-cell">
|
||||||
|
<span class="outbound-traffic-up">
|
||||||
|
<a-icon type="arrow-up"></a-icon>
|
||||||
|
[[ SizeFormatter.sizeFormat(findOutboundUp(outbound)) ]]
|
||||||
|
</span>
|
||||||
|
<span class="outbound-traffic-sep" aria-hidden="true"></span>
|
||||||
|
<span class="outbound-traffic-down">
|
||||||
|
<a-icon type="arrow-down"></a-icon>
|
||||||
|
[[ SizeFormatter.sizeFormat(findOutboundDown(outbound)) ]]
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="outbound-card-test">
|
||||||
|
<div v-if="outboundTestResult(index)">
|
||||||
|
<span v-if="outboundTestResult(index).success"
|
||||||
|
class="outbound-result-pill outbound-result-ok">
|
||||||
|
<a-icon type="check-circle" theme="filled"></a-icon>
|
||||||
|
[[ outboundTestResult(index).delay ]] ms
|
||||||
|
</span>
|
||||||
|
<a-tooltip v-else :title="outboundTestResult(index).error"
|
||||||
|
:overlay-class-name="themeSwitcher.currentTheme">
|
||||||
|
<span class="outbound-result-pill outbound-result-fail">
|
||||||
|
<a-icon type="close-circle" theme="filled"></a-icon>
|
||||||
|
{{ i18n "pages.xray.outbound.testFailed" }}
|
||||||
|
</span>
|
||||||
|
</a-tooltip>
|
||||||
|
</div>
|
||||||
|
<a-icon type="loading" class="outbound-result-loading"
|
||||||
|
v-else-if="isOutboundTesting(index)"></a-icon>
|
||||||
|
<a-button type="primary" shape="circle" size="small" icon="thunderbolt"
|
||||||
|
class="outbound-test-btn"
|
||||||
|
:loading="isOutboundTesting(index)"
|
||||||
|
@click="testOutbound(index)"
|
||||||
|
:disabled="isOutboundUntestable(outbound) || isOutboundTesting(index)">
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Desktop: table -->
|
||||||
|
<a-table v-if="!isMobile" :columns="outboundColumns" :row-key="r => r.key"
|
||||||
:data-source="outboundData"
|
:data-source="outboundData"
|
||||||
:scroll="isMobile ? { x: 720 } : {}"
|
:scroll="{}"
|
||||||
:pagination="false"
|
:pagination="false"
|
||||||
:indent-size="0"
|
:indent-size="0"
|
||||||
class="outbounds-table"
|
class="outbounds-table"
|
||||||
|
|
|
||||||
|
|
@ -169,8 +169,8 @@
|
||||||
// network + security pills sit underneath it. Width chosen so the three
|
// network + security pills sit underneath it. Width chosen so the three
|
||||||
// longest tonal pills (e.g. vless + httpupgrade + reality) fit on a
|
// longest tonal pills (e.g. vless + httpupgrade + reality) fit on a
|
||||||
// single line without wrapping.
|
// single line without wrapping.
|
||||||
{ title: '{{ i18n "pages.xray.outbound.tag"}}', align: 'left', width: 280, scopedSlots: { customRender: 'identity' } },
|
{ title: '{{ i18n "pages.xray.outbound.tag"}}', align: 'left', width: 190, scopedSlots: { customRender: 'identity' } },
|
||||||
{ title: '{{ i18n "pages.xray.outbound.address"}}', align: 'left', scopedSlots: { customRender: 'address' } },
|
{ title: '{{ i18n "pages.xray.outbound.address"}}', align: 'left', width: 230, scopedSlots: { customRender: 'address' } },
|
||||||
{ title: '{{ i18n "pages.inbounds.traffic" }}', align: 'left', width: 190, scopedSlots: { customRender: 'traffic' } },
|
{ title: '{{ i18n "pages.inbounds.traffic" }}', align: 'left', width: 190, scopedSlots: { customRender: 'traffic' } },
|
||||||
{ title: '{{ i18n "pages.xray.outbound.testResult" }}', align: 'left', width: 130, scopedSlots: { customRender: 'testResult' } },
|
{ title: '{{ i18n "pages.xray.outbound.testResult" }}', align: 'left', width: 130, scopedSlots: { customRender: 'testResult' } },
|
||||||
{ title: '{{ i18n "pages.xray.outbound.test" }}', align: 'center', width: 70, scopedSlots: { customRender: 'test' } },
|
{ title: '{{ i18n "pages.xray.outbound.test" }}', align: 'center', width: 70, scopedSlots: { customRender: 'test' } },
|
||||||
|
|
@ -1992,7 +1992,7 @@
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
max-width: 240px;
|
max-width: 190px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
@ -2273,5 +2273,89 @@
|
||||||
}
|
}
|
||||||
.light .xray-page .routing-target-icon { color: rgba(0, 0, 0, 0.45); }
|
.light .xray-page .routing-target-icon { color: rgba(0, 0, 0, 0.45); }
|
||||||
|
|
||||||
|
/* ───────── Mobile outbound cards ───────── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.xray-page .outbounds-toolbar-right { text-align: left; }
|
||||||
|
|
||||||
|
.xray-page .outbound-card-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px;
|
||||||
|
color: rgba(255, 255, 255, 0.35);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.light .xray-page .outbound-card-empty { color: rgba(0, 0, 0, 0.35); }
|
||||||
|
|
||||||
|
.xray-page .outbound-card {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
.light .xray-page .outbound-card {
|
||||||
|
background: rgba(0, 0, 0, 0.025);
|
||||||
|
border-color: rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.xray-page .outbound-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xray-page .outbound-card-identity {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xray-page .outbound-card-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xray-page .outbound-card-num {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(255, 255, 255, 0.35);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.light .xray-page .outbound-card-num { color: rgba(0, 0, 0, 0.35); }
|
||||||
|
|
||||||
|
.xray-page .outbound-card .outbound-tag { font-size: 14px; }
|
||||||
|
|
||||||
|
.xray-page .outbound-card .outbound-protocol-cell { flex-wrap: wrap; }
|
||||||
|
|
||||||
|
.xray-page .outbound-card-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xray-page .outbound-card .outbound-traffic-cell {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xray-page .outbound-card-test {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
{{ template "page/body_end" .}}
|
{{ template "page/body_end" .}}
|
||||||
|
|
@ -403,16 +403,6 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
|
||||||
shouldCleanLog := false
|
shouldCleanLog := false
|
||||||
j.disAllowedIps = []string{}
|
j.disAllowedIps = []string{}
|
||||||
|
|
||||||
// Open log file
|
|
||||||
logIpFile, err := os.OpenFile(xray.GetIPLimitLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
|
||||||
if err != nil {
|
|
||||||
logger.Errorf("failed to open IP limit log file: %s", err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
defer logIpFile.Close()
|
|
||||||
log.SetOutput(logIpFile)
|
|
||||||
log.SetFlags(log.LstdFlags)
|
|
||||||
|
|
||||||
// historical db-only ips are excluded from this count on purpose.
|
// historical db-only ips are excluded from this count on purpose.
|
||||||
var keptLive []IPWithTimestamp
|
var keptLive []IPWithTimestamp
|
||||||
if len(liveIps) > limitIp {
|
if len(liveIps) > limitIp {
|
||||||
|
|
@ -422,13 +412,25 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
|
||||||
keptLive = liveIps[:limitIp]
|
keptLive = liveIps[:limitIp]
|
||||||
bannedLive := liveIps[limitIp:]
|
bannedLive := liveIps[limitIp:]
|
||||||
|
|
||||||
|
// Open log file only when a ban entry needs to be written.
|
||||||
|
// Use a local logger to avoid mutating the global log.* state,
|
||||||
|
// which would redirect all standard-library logging to this file
|
||||||
|
// and leave a dangling closed-file handle after the defer fires.
|
||||||
|
logIpFile, err := os.OpenFile(xray.GetIPLimitLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("failed to open IP limit log file: %s", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
defer logIpFile.Close()
|
||||||
|
ipLogger := log.New(logIpFile, "", log.LstdFlags)
|
||||||
|
|
||||||
// log format is load-bearing: x-ui.sh create_iplimit_jails builds
|
// log format is load-bearing: x-ui.sh create_iplimit_jails builds
|
||||||
// filter.d/3x-ipl.conf with
|
// filter.d/3x-ipl.conf with
|
||||||
// failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnecting OLD IP\s*=\s*<ADDR>\s*\|\|\s*Timestamp\s*=\s*\d+
|
// failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnecting OLD IP\s*=\s*<ADDR>\s*\|\|\s*Timestamp\s*=\s*\d+
|
||||||
// don't change the wording.
|
// don't change the wording.
|
||||||
for _, ipTime := range bannedLive {
|
for _, ipTime := range bannedLive {
|
||||||
j.disAllowedIps = append(j.disAllowedIps, ipTime.IP)
|
j.disAllowedIps = append(j.disAllowedIps, ipTime.IP)
|
||||||
log.Printf("[LIMIT_IP] Email = %s || Disconnecting OLD IP = %s || Timestamp = %d", clientEmail, ipTime.IP, ipTime.Timestamp)
|
ipLogger.Printf("[LIMIT_IP] Email = %s || Disconnecting OLD IP = %s || Timestamp = %d", clientEmail, ipTime.IP, ipTime.Timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// force xray to drop existing connections from banned ips
|
// force xray to drop existing connections from banned ips
|
||||||
|
|
|
||||||
|
|
@ -1213,6 +1213,28 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
|
||||||
settingsClients[clientIndex] = interfaceClients[0]
|
settingsClients[clientIndex] = interfaceClients[0]
|
||||||
oldSettings["clients"] = settingsClients
|
oldSettings["clients"] = settingsClients
|
||||||
|
|
||||||
|
// testseed is only meaningful when at least one VLESS client uses the exact
|
||||||
|
// xtls-rprx-vision flow. The client-edit path only rewrites a single client,
|
||||||
|
// so re-check the flow set here and strip a stale testseed when nothing in the
|
||||||
|
// inbound still warrants it. The full-inbound update path already handles this
|
||||||
|
// on the JS side via VLESSSettings.toJson().
|
||||||
|
if oldInbound.Protocol == model.VLESS {
|
||||||
|
hasVisionFlow := false
|
||||||
|
for _, c := range settingsClients {
|
||||||
|
cm, ok := c.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if flow, _ := cm["flow"].(string); flow == "xtls-rprx-vision" {
|
||||||
|
hasVisionFlow = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasVisionFlow {
|
||||||
|
delete(oldSettings, "testseed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
newSettings, err := json.MarshalIndent(oldSettings, "", " ")
|
newSettings, err := json.MarshalIndent(oldSettings, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
|
|
@ -2897,6 +2919,7 @@ func (s *InboundService) MigrationRequirements() {
|
||||||
if ok {
|
if ok {
|
||||||
// Fix Client configuration problems
|
// Fix Client configuration problems
|
||||||
var newClients []any
|
var newClients []any
|
||||||
|
hasVisionFlow := false
|
||||||
for client_index := range clients {
|
for client_index := range clients {
|
||||||
c := clients[client_index].(map[string]any)
|
c := clients[client_index].(map[string]any)
|
||||||
|
|
||||||
|
|
@ -2922,6 +2945,9 @@ func (s *InboundService) MigrationRequirements() {
|
||||||
c["flow"] = ""
|
c["flow"] = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if flow, _ := c["flow"].(string); flow == "xtls-rprx-vision" {
|
||||||
|
hasVisionFlow = true
|
||||||
|
}
|
||||||
// Backfill created_at and updated_at
|
// Backfill created_at and updated_at
|
||||||
if _, ok := c["created_at"]; !ok {
|
if _, ok := c["created_at"]; !ok {
|
||||||
c["created_at"] = time.Now().Unix() * 1000
|
c["created_at"] = time.Now().Unix() * 1000
|
||||||
|
|
@ -2930,6 +2956,15 @@ func (s *InboundService) MigrationRequirements() {
|
||||||
newClients = append(newClients, any(c))
|
newClients = append(newClients, any(c))
|
||||||
}
|
}
|
||||||
settings["clients"] = newClients
|
settings["clients"] = newClients
|
||||||
|
|
||||||
|
// Drop orphaned testseed: VLESS-only field, only meaningful when at least
|
||||||
|
// one client uses the exact xtls-rprx-vision flow. Older versions saved it
|
||||||
|
// for any non-empty flow (including the UDP variant) or kept it after the
|
||||||
|
// flow was cleared from the client modal — clean those up here.
|
||||||
|
if inbounds[inbound_index].Protocol == model.VLESS && !hasVisionFlow {
|
||||||
|
delete(settings, "testseed")
|
||||||
|
}
|
||||||
|
|
||||||
modifiedSettings, err := json.MarshalIndent(settings, "", " ")
|
modifiedSettings, err := json.MarshalIndent(settings, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -315,13 +315,21 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Network stats
|
// Network stats
|
||||||
ioStats, err := net.IOCounters(false)
|
ioStats, err := net.IOCounters(true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warning("get io counters failed:", err)
|
logger.Warning("get io counters failed:", err)
|
||||||
} else if len(ioStats) > 0 {
|
} else {
|
||||||
ioStat := ioStats[0]
|
var totalSent, totalRecv uint64
|
||||||
status.NetTraffic.Sent = ioStat.BytesSent
|
for _, iface := range ioStats {
|
||||||
status.NetTraffic.Recv = ioStat.BytesRecv
|
name := strings.ToLower(iface.Name)
|
||||||
|
if isVirtualInterface(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
totalSent += iface.BytesSent
|
||||||
|
totalRecv += iface.BytesRecv
|
||||||
|
}
|
||||||
|
status.NetTraffic.Sent = totalSent
|
||||||
|
status.NetTraffic.Recv = totalRecv
|
||||||
|
|
||||||
if lastStatus != nil {
|
if lastStatus != nil {
|
||||||
duration := now.Sub(lastStatus.T)
|
duration := now.Sub(lastStatus.T)
|
||||||
|
|
@ -331,8 +339,6 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
|
||||||
status.NetIO.Up = up
|
status.NetIO.Up = up
|
||||||
status.NetIO.Down = down
|
status.NetIO.Down = down
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
logger.Warning("can not find io counters")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TCP/UDP connections
|
// TCP/UDP connections
|
||||||
|
|
@ -860,6 +866,34 @@ func (s *ServerService) GetXrayLogs(
|
||||||
return entries
|
return entries
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isVirtualInterface returns true for loopback and virtual/tunnel interfaces
|
||||||
|
// that should be excluded from network traffic statistics.
|
||||||
|
func isVirtualInterface(name string) bool {
|
||||||
|
// Exact matches
|
||||||
|
if name == "lo" || name == "lo0" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Prefix matches for virtual/tunnel interfaces
|
||||||
|
virtualPrefixes := []string{
|
||||||
|
"loopback",
|
||||||
|
"docker",
|
||||||
|
"br-",
|
||||||
|
"veth",
|
||||||
|
"virbr",
|
||||||
|
"tun",
|
||||||
|
"tap",
|
||||||
|
"wg",
|
||||||
|
"tailscale",
|
||||||
|
"zt",
|
||||||
|
}
|
||||||
|
for _, prefix := range virtualPrefixes {
|
||||||
|
if strings.HasPrefix(name, prefix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func logEntryContains(line string, suffixes []string) bool {
|
func logEntryContains(line string, suffixes []string) bool {
|
||||||
for _, sfx := range suffixes {
|
for _, sfx := range suffixes {
|
||||||
if strings.Contains(line, sfx+"]") {
|
if strings.Contains(line, sfx+"]") {
|
||||||
|
|
|
||||||
8
x-ui.sh
8
x-ui.sh
|
|
@ -2034,14 +2034,14 @@ backend=auto
|
||||||
filter=3x-ipl
|
filter=3x-ipl
|
||||||
action=3x-ipl
|
action=3x-ipl
|
||||||
logpath=${iplimit_log_path}
|
logpath=${iplimit_log_path}
|
||||||
maxretry=2
|
maxretry=1
|
||||||
findtime=32
|
findtime=32
|
||||||
bantime=${bantime}m
|
bantime=${bantime}m
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
cat << EOF > /etc/fail2ban/filter.d/3x-ipl.conf
|
cat << EOF > /etc/fail2ban/filter.d/3x-ipl.conf
|
||||||
[Definition]
|
[Definition]
|
||||||
datepattern = ^%%Y/%%m/%%d %%H:%%M:%%S
|
datepattern = ^%Y/%m/%d %H:%M:%S
|
||||||
failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnecting OLD IP\s*=\s*<ADDR>\s*\|\|\s*Timestamp\s*=\s*\d+
|
failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnecting OLD IP\s*=\s*<ADDR>\s*\|\|\s*Timestamp\s*=\s*\d+
|
||||||
ignoreregex =
|
ignoreregex =
|
||||||
EOF
|
EOF
|
||||||
|
|
@ -2062,10 +2062,10 @@ actionstop = <iptables> -D <chain> -p <protocol> -j f2b-<name>
|
||||||
actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]'
|
actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]'
|
||||||
|
|
||||||
actionban = <iptables> -I f2b-<name> 1 -s <ip> -j <blocktype>
|
actionban = <iptables> -I f2b-<name> 1 -s <ip> -j <blocktype>
|
||||||
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") BAN [Email] = <F-USER> [IP] = <ip> banned for <bantime> seconds." >> ${iplimit_banned_log_path}
|
echo "\$(date +"%Y/%m/%d %H:%M:%S") BAN [Email] = <F-USER> [IP] = <ip> banned for <bantime> seconds." >> ${iplimit_banned_log_path}
|
||||||
|
|
||||||
actionunban = <iptables> -D f2b-<name> -s <ip> -j <blocktype>
|
actionunban = <iptables> -D f2b-<name> -s <ip> -j <blocktype>
|
||||||
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") UNBAN [Email] = <F-USER> [IP] = <ip> unbanned." >> ${iplimit_banned_log_path}
|
echo "\$(date +"%Y/%m/%d %H:%M:%S") UNBAN [Email] = <F-USER> [IP] = <ip> unbanned." >> ${iplimit_banned_log_path}
|
||||||
|
|
||||||
[Init]
|
[Init]
|
||||||
name = default
|
name = default
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue