mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 21:24:10 +00:00
Merge branch 'MHSanaei:main' into Feat-WireGuard-inbound-DNS
This commit is contained in:
commit
4b92228c28
34 changed files with 1094 additions and 461 deletions
|
|
@ -1 +1 @@
|
||||||
2.9.3
|
2.9.4
|
||||||
|
|
@ -129,22 +129,27 @@ type CustomGeoResource struct {
|
||||||
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime;column:updated_at"`
|
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime;column:updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ClientReverse struct {
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
}
|
||||||
|
|
||||||
// Client represents a client configuration for Xray inbounds with traffic limits and settings.
|
// Client represents a client configuration for Xray inbounds with traffic limits and settings.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
ID string `json:"id,omitempty"` // Unique client identifier
|
ID string `json:"id,omitempty"` // Unique client identifier
|
||||||
Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm")
|
Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm")
|
||||||
Password string `json:"password,omitempty"` // Client password
|
Password string `json:"password,omitempty"` // Client password
|
||||||
Flow string `json:"flow,omitempty"` // Flow control (XTLS)
|
Flow string `json:"flow,omitempty"` // Flow control (XTLS)
|
||||||
Auth string `json:"auth,omitempty"` // Auth password (Hysteria)
|
Reverse *ClientReverse `json:"reverse,omitempty"` // VLESS simple reverse proxy settings
|
||||||
Email string `json:"email"` // Client email identifier
|
Auth string `json:"auth,omitempty"` // Auth password (Hysteria)
|
||||||
LimitIP int `json:"limitIp"` // IP limit for this client
|
Email string `json:"email"` // Client email identifier
|
||||||
TotalGB int64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB
|
LimitIP int `json:"limitIp"` // IP limit for this client
|
||||||
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
|
TotalGB int64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB
|
||||||
Enable bool `json:"enable" form:"enable"` // Whether the client is enabled
|
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
|
||||||
TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications
|
Enable bool `json:"enable" form:"enable"` // Whether the client is enabled
|
||||||
SubID string `json:"subId" form:"subId"` // Subscription identifier
|
TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications
|
||||||
Comment string `json:"comment" form:"comment"` // Client comment
|
SubID string `json:"subId" form:"subId"` // Subscription identifier
|
||||||
Reset int `json:"reset" form:"reset"` // Reset period in days
|
Comment string `json:"comment" form:"comment"` // Client comment
|
||||||
CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp
|
Reset int `json:"reset" form:"reset"` // Reset period in days
|
||||||
UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp
|
CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp
|
||||||
|
UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ func NewSubJsonService(fragment string, noises string, mux string, rules string,
|
||||||
fragmentOrNoises := false
|
fragmentOrNoises := false
|
||||||
if fragment != "" || noises != "" {
|
if fragment != "" || noises != "" {
|
||||||
fragmentOrNoises = true
|
fragmentOrNoises = true
|
||||||
defaultOutboundsSettings := map[string]interface{}{
|
defaultOutboundsSettings := map[string]any{
|
||||||
"domainStrategy": "UseIP",
|
"domainStrategy": "UseIP",
|
||||||
"redirect": "",
|
"redirect": "",
|
||||||
}
|
}
|
||||||
|
|
@ -57,7 +57,7 @@ func NewSubJsonService(fragment string, noises string, mux string, rules string,
|
||||||
defaultOutboundsSettings["noises"] = json_util.RawMessage(noises)
|
defaultOutboundsSettings["noises"] = json_util.RawMessage(noises)
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultDirectOutbound := map[string]interface{}{
|
defaultDirectOutbound := map[string]any{
|
||||||
"protocol": "freedom",
|
"protocol": "freedom",
|
||||||
"settings": defaultOutboundsSettings,
|
"settings": defaultOutboundsSettings,
|
||||||
"tag": "direct_out",
|
"tag": "direct_out",
|
||||||
|
|
|
||||||
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
|
|
@ -2617,27 +2617,39 @@ Inbound.VLESSSettings.VLESS = class extends Inbound.ClientBase {
|
||||||
constructor(
|
constructor(
|
||||||
id = RandomUtil.randomUUID(),
|
id = RandomUtil.randomUUID(),
|
||||||
flow = '',
|
flow = '',
|
||||||
|
reverseTag = '',
|
||||||
|
reverseSniffing = new Sniffing(),
|
||||||
email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at,
|
email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at,
|
||||||
) {
|
) {
|
||||||
super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at);
|
super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at);
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.flow = flow;
|
this.flow = flow;
|
||||||
|
this.reverseTag = reverseTag;
|
||||||
|
this.reverseSniffing = reverseSniffing;
|
||||||
}
|
}
|
||||||
|
|
||||||
static fromJson(json = {}) {
|
static fromJson(json = {}) {
|
||||||
return new Inbound.VLESSSettings.VLESS(
|
return new Inbound.VLESSSettings.VLESS(
|
||||||
json.id,
|
json.id,
|
||||||
json.flow,
|
json.flow,
|
||||||
|
json.reverse?.tag ?? '',
|
||||||
|
Sniffing.fromJson(json.reverse?.sniffing || {}),
|
||||||
...Inbound.ClientBase.commonArgsFromJson(json),
|
...Inbound.ClientBase.commonArgsFromJson(json),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
toJson() {
|
toJson() {
|
||||||
return {
|
const json = {
|
||||||
id: this.id,
|
id: this.id,
|
||||||
flow: this.flow,
|
flow: this.flow,
|
||||||
...this._clientBaseToJson(),
|
...this._clientBaseToJson(),
|
||||||
};
|
};
|
||||||
|
if (this.reverseTag) {
|
||||||
|
json.reverse = {
|
||||||
|
tag: this.reverseTag,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return json;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,13 @@ const ALPN_OPTION = {
|
||||||
HTTP1: "http/1.1",
|
HTTP1: "http/1.1",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SNIFFING_OPTION = {
|
||||||
|
HTTP: "http",
|
||||||
|
TLS: "tls",
|
||||||
|
QUIC: "quic",
|
||||||
|
FAKEDNS: "fakedns"
|
||||||
|
};
|
||||||
|
|
||||||
const OutboundDomainStrategies = [
|
const OutboundDomainStrategies = [
|
||||||
"AsIs",
|
"AsIs",
|
||||||
"UseIP",
|
"UseIP",
|
||||||
|
|
@ -170,6 +177,7 @@ Object.freeze(SSMethods);
|
||||||
Object.freeze(TLS_FLOW_CONTROL);
|
Object.freeze(TLS_FLOW_CONTROL);
|
||||||
Object.freeze(UTLS_FINGERPRINT);
|
Object.freeze(UTLS_FINGERPRINT);
|
||||||
Object.freeze(ALPN_OPTION);
|
Object.freeze(ALPN_OPTION);
|
||||||
|
Object.freeze(SNIFFING_OPTION);
|
||||||
Object.freeze(OutboundDomainStrategies);
|
Object.freeze(OutboundDomainStrategies);
|
||||||
Object.freeze(WireguardDomainStrategy);
|
Object.freeze(WireguardDomainStrategy);
|
||||||
Object.freeze(USERS_SECURITY);
|
Object.freeze(USERS_SECURITY);
|
||||||
|
|
@ -196,6 +204,50 @@ class CommonClass {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ReverseSniffing extends CommonClass {
|
||||||
|
constructor(
|
||||||
|
enabled = false,
|
||||||
|
destOverride = ['http', 'tls', 'quic', 'fakedns'],
|
||||||
|
metadataOnly = false,
|
||||||
|
routeOnly = false,
|
||||||
|
ipsExcluded = [],
|
||||||
|
domainsExcluded = [],
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
this.enabled = enabled;
|
||||||
|
this.destOverride = Array.isArray(destOverride) && destOverride.length > 0 ? destOverride : ['http', 'tls', 'quic', 'fakedns'];
|
||||||
|
this.metadataOnly = metadataOnly;
|
||||||
|
this.routeOnly = routeOnly;
|
||||||
|
this.ipsExcluded = Array.isArray(ipsExcluded) ? ipsExcluded : [];
|
||||||
|
this.domainsExcluded = Array.isArray(domainsExcluded) ? domainsExcluded : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJson(json = {}) {
|
||||||
|
if (!json || Object.keys(json).length === 0) {
|
||||||
|
return new ReverseSniffing();
|
||||||
|
}
|
||||||
|
return new ReverseSniffing(
|
||||||
|
!!json.enabled,
|
||||||
|
json.destOverride,
|
||||||
|
json.metadataOnly,
|
||||||
|
json.routeOnly,
|
||||||
|
json.ipsExcluded || [],
|
||||||
|
json.domainsExcluded || [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
toJson() {
|
||||||
|
return {
|
||||||
|
enabled: this.enabled,
|
||||||
|
destOverride: this.destOverride,
|
||||||
|
metadataOnly: this.metadataOnly,
|
||||||
|
routeOnly: this.routeOnly,
|
||||||
|
ipsExcluded: this.ipsExcluded.length > 0 ? this.ipsExcluded : undefined,
|
||||||
|
domainsExcluded: this.domainsExcluded.length > 0 ? this.domainsExcluded : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class TcpStreamSettings extends CommonClass {
|
class TcpStreamSettings extends CommonClass {
|
||||||
constructor(type = 'none', host, path) {
|
constructor(type = 'none', host, path) {
|
||||||
super();
|
super();
|
||||||
|
|
@ -1747,13 +1799,15 @@ Outbound.VmessSettings = class extends CommonClass {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
Outbound.VLESSSettings = class extends CommonClass {
|
Outbound.VLESSSettings = class extends CommonClass {
|
||||||
constructor(address, port, id, flow, encryption, testpre = 0, testseed = [900, 500, 900, 256]) {
|
constructor(address, port, id, flow, encryption, reverseTag = '', reverseSniffing = new ReverseSniffing(), testpre = 0, testseed = [900, 500, 900, 256]) {
|
||||||
super();
|
super();
|
||||||
this.address = address;
|
this.address = address;
|
||||||
this.port = port;
|
this.port = port;
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.flow = flow;
|
this.flow = flow;
|
||||||
this.encryption = encryption;
|
this.encryption = encryption;
|
||||||
|
this.reverseTag = reverseTag;
|
||||||
|
this.reverseSniffing = reverseSniffing;
|
||||||
this.testpre = testpre;
|
this.testpre = testpre;
|
||||||
this.testseed = testseed;
|
this.testseed = testseed;
|
||||||
}
|
}
|
||||||
|
|
@ -1766,6 +1820,8 @@ Outbound.VLESSSettings = class extends CommonClass {
|
||||||
json.id,
|
json.id,
|
||||||
json.flow,
|
json.flow,
|
||||||
json.encryption,
|
json.encryption,
|
||||||
|
json.reverse?.tag || '',
|
||||||
|
ReverseSniffing.fromJson(json.reverse?.sniffing || {}),
|
||||||
json.testpre || 0,
|
json.testpre || 0,
|
||||||
json.testseed && json.testseed.length >= 4 ? json.testseed : [900, 500, 900, 256]
|
json.testseed && json.testseed.length >= 4 ? json.testseed : [900, 500, 900, 256]
|
||||||
);
|
);
|
||||||
|
|
@ -1779,6 +1835,14 @@ Outbound.VLESSSettings = class extends CommonClass {
|
||||||
flow: this.flow,
|
flow: this.flow,
|
||||||
encryption: this.encryption,
|
encryption: this.encryption,
|
||||||
};
|
};
|
||||||
|
if (!ObjectUtil.isEmpty(this.reverseTag)) {
|
||||||
|
const reverseSniffing = this.reverseSniffing ? this.reverseSniffing.toJson() : {};
|
||||||
|
const defaultReverseSniffing = new ReverseSniffing().toJson();
|
||||||
|
result.reverse = {
|
||||||
|
tag: this.reverseTag,
|
||||||
|
sniffing: JSON.stringify(reverseSniffing) === JSON.stringify(defaultReverseSniffing) ? {} : reverseSniffing,
|
||||||
|
};
|
||||||
|
}
|
||||||
// Only include Vision settings when flow is set
|
// Only include Vision settings when flow is set
|
||||||
if (this.flow && this.flow !== '') {
|
if (this.flow && this.flow !== '') {
|
||||||
if (this.testpre > 0) {
|
if (this.testpre > 0) {
|
||||||
|
|
|
||||||
|
|
@ -71,14 +71,19 @@ func (a *XraySettingController) getXraySetting(c *gin.Context) {
|
||||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
|
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
clientReverseTags, err := a.InboundService.GetClientReverseTags()
|
||||||
|
if err != nil {
|
||||||
|
clientReverseTags = "[]"
|
||||||
|
}
|
||||||
outboundTestUrl, _ := a.SettingService.GetXrayOutboundTestUrl()
|
outboundTestUrl, _ := a.SettingService.GetXrayOutboundTestUrl()
|
||||||
if outboundTestUrl == "" {
|
if outboundTestUrl == "" {
|
||||||
outboundTestUrl = "https://www.google.com/generate_204"
|
outboundTestUrl = "https://www.google.com/generate_204"
|
||||||
}
|
}
|
||||||
xrayResponse := map[string]interface{}{
|
xrayResponse := map[string]any{
|
||||||
"xraySetting": json.RawMessage(xraySetting),
|
"xraySetting": json.RawMessage(xraySetting),
|
||||||
"inboundTags": json.RawMessage(inboundTags),
|
"inboundTags": json.RawMessage(inboundTags),
|
||||||
"outboundTestUrl": outboundTestUrl,
|
"clientReverseTags": json.RawMessage(clientReverseTags),
|
||||||
|
"outboundTestUrl": outboundTestUrl,
|
||||||
}
|
}
|
||||||
result, err := json.Marshal(xrayResponse)
|
result, err := json.Marshal(xrayResponse)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@
|
||||||
</a-popover>
|
</a-popover>
|
||||||
</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>
|
||||||
|
|
@ -65,8 +65,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">
|
||||||
|
|
@ -94,7 +96,7 @@
|
||||||
</table>
|
</table>
|
||||||
</template>
|
</template>
|
||||||
<div class="tr-table-box">
|
<div class="tr-table-box">
|
||||||
<div class="tr-table-rt">[[ SizeFormatter.sizeFormat(getSumStats(record, client.email)) ]]</div>
|
<div class="tr-table-lt">[[ 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>
|
||||||
|
|
@ -104,7 +106,7 @@
|
||||||
<div v-else class="infinite-bar tr-table-bar">
|
<div v-else class="infinite-bar tr-table-bar">
|
||||||
<a-progress :show-info="false" :percent="100"></a-progress>
|
<a-progress :show-info="false" :percent="100"></a-progress>
|
||||||
</div>
|
</div>
|
||||||
<div class="tr-table-lt">
|
<div class="tr-table-rt">
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -184,20 +186,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%">
|
||||||
|
|
@ -216,11 +218,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>
|
||||||
|
|
@ -233,9 +235,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>
|
||||||
|
|
@ -245,7 +247,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' }">
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,18 @@
|
||||||
<a-select-option v-for="key in TLS_FLOW_CONTROL" :value="key">[[ key ]]</a-select-option>
|
<a-select-option v-for="key in TLS_FLOW_CONTROL" :value="key">[[ key ]]</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
<a-form-item v-if="inbound.protocol === Protocols.VLESS">
|
||||||
|
<template slot="label">
|
||||||
|
<a-tooltip>
|
||||||
|
<template slot="title">
|
||||||
|
<span>{{ i18n "pages.xray.outbound.reverseTagDesc" }}</span>
|
||||||
|
</template>
|
||||||
|
{{ i18n "pages.xray.outbound.reverseTag" }}
|
||||||
|
<a-icon type="question-circle"></a-icon>
|
||||||
|
</a-tooltip>
|
||||||
|
</template>
|
||||||
|
<a-input v-model.trim="client.reverseTag" :placeholder='`{{ i18n "pages.xray.outbound.reverseTagPlaceholder" }}`'></a-input>
|
||||||
|
</a-form-item>
|
||||||
<a-form-item>
|
<a-form-item>
|
||||||
<template slot="label">
|
<template slot="label">
|
||||||
<a-tooltip>
|
<a-tooltip>
|
||||||
|
|
|
||||||
|
|
@ -342,6 +342,53 @@
|
||||||
<a-form-item label="encryption">
|
<a-form-item label="encryption">
|
||||||
<a-input v-model.trim="outbound.settings.encryption"></a-input>
|
<a-input v-model.trim="outbound.settings.encryption"></a-input>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
<a-form-item>
|
||||||
|
<template slot="label">
|
||||||
|
<a-tooltip>
|
||||||
|
<template slot="title">
|
||||||
|
<span>{{ i18n "pages.xray.outbound.reverseTagDesc" }}</span>
|
||||||
|
</template>
|
||||||
|
{{ i18n "pages.xray.outbound.reverseTag" }}
|
||||||
|
<a-icon type="question-circle"></a-icon>
|
||||||
|
</a-tooltip>
|
||||||
|
</template>
|
||||||
|
<a-input v-model.trim="outbound.settings.reverseTag" :placeholder='`{{ i18n "pages.xray.outbound.reverseTagPlaceholder" }}`'></a-input>
|
||||||
|
</a-form-item>
|
||||||
|
<template v-if="outbound.settings.reverseTag">
|
||||||
|
<a-form-item>
|
||||||
|
<span slot="label">
|
||||||
|
Reverse Sniffing
|
||||||
|
<a-tooltip>
|
||||||
|
<template slot="title">
|
||||||
|
<span>{{ i18n "pages.inbounds.noRecommendKeepDefault" }}</span>
|
||||||
|
</template>
|
||||||
|
<a-icon type="question-circle"></a-icon>
|
||||||
|
</a-tooltip>
|
||||||
|
</span>
|
||||||
|
<a-switch v-model="outbound.settings.reverseSniffing.enabled"></a-switch>
|
||||||
|
</a-form-item>
|
||||||
|
<template v-if="outbound.settings.reverseSniffing.enabled">
|
||||||
|
<a-form-item :wrapper-col="{span:24}">
|
||||||
|
<a-checkbox-group v-model="outbound.settings.reverseSniffing.destOverride">
|
||||||
|
<a-checkbox v-for="key,value in SNIFFING_OPTION" :value="key">[[ value ]]</a-checkbox>
|
||||||
|
</a-checkbox-group>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Metadata Only">
|
||||||
|
<a-switch v-model="outbound.settings.reverseSniffing.metadataOnly"></a-switch>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Route Only">
|
||||||
|
<a-switch v-model="outbound.settings.reverseSniffing.routeOnly"></a-switch>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="IPs Excluded">
|
||||||
|
<a-select mode="tags" v-model="outbound.settings.reverseSniffing.ipsExcluded" :style="{ width: '100%' }"
|
||||||
|
:token-separators="[',']" placeholder="IP/CIDR/geoip:*/ext:*"></a-select>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Domains Excluded">
|
||||||
|
<a-select mode="tags" v-model="outbound.settings.reverseSniffing.domainsExcluded" :style="{ width: '100%' }"
|
||||||
|
:token-separators="[',']" placeholder="domain:*/ext:*"></a-select>
|
||||||
|
</a-form-item>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="outbound.canEnableTlsFlow()">
|
<template v-if="outbound.canEnableTlsFlow()">
|
||||||
<a-form-item label="Flow">
|
<a-form-item label="Flow">
|
||||||
|
|
|
||||||
|
|
@ -648,7 +648,7 @@
|
||||||
:data-source="getInboundClients(record)"
|
:data-source="getInboundClients(record)"
|
||||||
:pagination=pagination(getInboundClients(record))
|
:pagination=pagination(getInboundClients(record))
|
||||||
:scroll="isMobile ? {} : { x: 'max-content' }"
|
:scroll="isMobile ? {} : { x: 'max-content' }"
|
||||||
:style="{ margin: `-10px ${isMobile ? '2px' : '22px'} -11px` }">
|
:style="{ margin: `-10px ${isMobile ? '2px' : '22px'} -21px` }">
|
||||||
{{template "component/aClientTable" .}}
|
{{template "component/aClientTable" .}}
|
||||||
</a-table>
|
</a-table>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,164 +0,0 @@
|
||||||
{{define "modals/reverseModal"}}
|
|
||||||
<a-modal id="reverse-modal" v-model="reverseModal.visible" :title="reverseModal.title" @ok="reverseModal.ok"
|
|
||||||
:confirm-loading="reverseModal.confirmLoading" :closable="true" :mask-closable="false"
|
|
||||||
:ok-text="reverseModal.okText" cancel-text='{{ i18n "close" }}' :class="themeSwitcher.currentTheme">
|
|
||||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
|
||||||
<a-form-item label='{{ i18n "pages.xray.outbound.type" }}'>
|
|
||||||
<a-select v-model="reverseModal.reverse.type" :dropdown-class-name="themeSwitcher.currentTheme">
|
|
||||||
<a-select-option v-for="x,y in reverseTypes" :value="y">[[ x ]]</a-select-option>
|
|
||||||
</a-select>
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label='{{ i18n "pages.xray.outbound.tag" }}'>
|
|
||||||
<a-input v-model.trim="reverseModal.reverse.tag"></a-input>
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label='{{ i18n "pages.xray.outbound.domain" }}'>
|
|
||||||
<a-input v-model.trim="reverseModal.reverse.domain"></a-input>
|
|
||||||
</a-form-item>
|
|
||||||
<template v-if="reverseModal.reverse.type=='bridge'">
|
|
||||||
<a-form-item label='{{ i18n "pages.xray.outbound.intercon" }}'>
|
|
||||||
<a-select v-model="reverseModal.rules[0].outboundTag" :dropdown-class-name="themeSwitcher.currentTheme">
|
|
||||||
<a-select-option v-for="x in reverseModal.outboundTags" :value="x">[[ x ]]</a-select-option>
|
|
||||||
</a-select>
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label='{{ i18n "pages.xray.rules.outbound" }}'>
|
|
||||||
<a-select v-model="reverseModal.rules[1].outboundTag" :dropdown-class-name="themeSwitcher.currentTheme">
|
|
||||||
<a-select-option v-for="x in reverseModal.outboundTags" :value="x">[[ x ]]</a-select-option>
|
|
||||||
</a-select>
|
|
||||||
</a-form-item>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<a-form-item label='{{ i18n "pages.xray.outbound.intercon" }}'>
|
|
||||||
<a-checkbox-group v-model="reverseModal.rules[0].inboundTag"
|
|
||||||
:options="reverseModal.inboundTags"></a-checkbox-group>
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label='{{ i18n "pages.xray.rules.inbound" }}'>
|
|
||||||
<a-checkbox-group v-model="reverseModal.rules[1].inboundTag"
|
|
||||||
:options="reverseModal.inboundTags"></a-checkbox-group>
|
|
||||||
</a-form-item>
|
|
||||||
</template>
|
|
||||||
</a-form>
|
|
||||||
</a-modal>
|
|
||||||
<script>
|
|
||||||
const reverseModal = {
|
|
||||||
title: '',
|
|
||||||
visible: false,
|
|
||||||
confirmLoading: false,
|
|
||||||
okText: '{{ i18n "sure" }}',
|
|
||||||
isEdit: false,
|
|
||||||
confirm: null,
|
|
||||||
reverse: {
|
|
||||||
tag: "",
|
|
||||||
type: "",
|
|
||||||
domain: ""
|
|
||||||
},
|
|
||||||
rules: [{
|
|
||||||
outboundTag: '',
|
|
||||||
inboundTag: []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
outboundTag: '',
|
|
||||||
inboundTag: []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
inboundTags: [],
|
|
||||||
outboundTags: [],
|
|
||||||
ok() {
|
|
||||||
reverseModal.rules[0].domain = ["full:" + reverseModal.reverse.domain];
|
|
||||||
reverseModal.rules[0].type = 'field';
|
|
||||||
reverseModal.rules[1].type = 'field';
|
|
||||||
|
|
||||||
if (reverseModal.reverse.type == 'bridge') {
|
|
||||||
reverseModal.rules[0].inboundTag = [reverseModal.reverse.tag];
|
|
||||||
reverseModal.rules[1].inboundTag = [reverseModal.reverse.tag];
|
|
||||||
} else {
|
|
||||||
reverseModal.rules[0].outboundTag = reverseModal.reverse.tag;
|
|
||||||
reverseModal.rules[1].outboundTag = reverseModal.reverse.tag;
|
|
||||||
}
|
|
||||||
ObjectUtil.execute(reverseModal.confirm, reverseModal.reverse, reverseModal.rules);
|
|
||||||
},
|
|
||||||
show({
|
|
||||||
title = '',
|
|
||||||
okText = '{{ i18n "sure" }}',
|
|
||||||
reverse,
|
|
||||||
rules,
|
|
||||||
confirm = (reverse, rules) => {},
|
|
||||||
isEdit = false
|
|
||||||
}) {
|
|
||||||
this.title = title;
|
|
||||||
this.okText = okText;
|
|
||||||
this.confirm = confirm;
|
|
||||||
this.visible = true;
|
|
||||||
if (isEdit) {
|
|
||||||
this.reverse = {
|
|
||||||
tag: reverse.tag,
|
|
||||||
type: reverse.type,
|
|
||||||
domain: reverse.domain,
|
|
||||||
};
|
|
||||||
reverse;
|
|
||||||
rules0 = rules.filter(r => r.domain != null);
|
|
||||||
if (rules0.length == 0) rules0 = [{
|
|
||||||
outboundTag: '',
|
|
||||||
domain: ["full:" + this.reverse.domain],
|
|
||||||
inboundTag: []
|
|
||||||
}];
|
|
||||||
rules1 = rules.filter(r => r.domain == null);
|
|
||||||
if (rules1.length == 0) rules1 = [{
|
|
||||||
outboundTag: '',
|
|
||||||
inboundTag: []
|
|
||||||
}];
|
|
||||||
this.rules = [];
|
|
||||||
this.rules.push({
|
|
||||||
domain: rules0[0].domain,
|
|
||||||
outboundTag: rules0[0].outboundTag,
|
|
||||||
inboundTag: rules0.map(r => r.inboundTag).flat()
|
|
||||||
});
|
|
||||||
this.rules.push({
|
|
||||||
outboundTag: rules1[0].outboundTag,
|
|
||||||
inboundTag: rules1.map(r => r.inboundTag).flat()
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.reverse = {
|
|
||||||
tag: "reverse-" + app.reverseData.length,
|
|
||||||
type: "bridge",
|
|
||||||
domain: "reverse.xui"
|
|
||||||
}
|
|
||||||
this.rules = [{
|
|
||||||
outboundTag: '',
|
|
||||||
inboundTag: []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
outboundTag: '',
|
|
||||||
inboundTag: []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
this.isEdit = isEdit;
|
|
||||||
this.inboundTags = app.templateSettings.inbounds.filter((i) => !ObjectUtil.isEmpty(i.tag)).map(obj =>
|
|
||||||
obj.tag);
|
|
||||||
this.inboundTags.push(...app.inboundTags);
|
|
||||||
if (app.enableDNS && !ObjectUtil.isEmpty(app.dnsTag)) this.inboundTags.push(app.dnsTag)
|
|
||||||
this.outboundTags = app.templateSettings.outbounds.filter((o) => !ObjectUtil.isEmpty(o.tag)).map(obj =>
|
|
||||||
obj.tag);
|
|
||||||
},
|
|
||||||
close() {
|
|
||||||
reverseModal.visible = false;
|
|
||||||
reverseModal.loading(false);
|
|
||||||
},
|
|
||||||
loading(loading = true) {
|
|
||||||
reverseModal.confirmLoading = loading;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
new Vue({
|
|
||||||
delimiters: ['[[', ']]'],
|
|
||||||
el: '#reverse-modal',
|
|
||||||
data: {
|
|
||||||
reverseModal: reverseModal,
|
|
||||||
reverseTypes: {
|
|
||||||
bridge: '{{ i18n "pages.xray.outbound.bridge" }}',
|
|
||||||
portal: '{{ i18n "pages.xray.outbound.portal" }}'
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{{end}}
|
|
||||||
|
|
@ -204,12 +204,12 @@
|
||||||
if (app.enableDNS && !ObjectUtil.isEmpty(app.dnsTag)) this.inboundTags.push(app.dnsTag)
|
if (app.enableDNS && !ObjectUtil.isEmpty(app.dnsTag)) this.inboundTags.push(app.dnsTag)
|
||||||
this.outboundTags = ["", ...app.templateSettings.outbounds.filter((o) => !ObjectUtil.isEmpty(o.tag)).map(obj =>
|
this.outboundTags = ["", ...app.templateSettings.outbounds.filter((o) => !ObjectUtil.isEmpty(o.tag)).map(obj =>
|
||||||
obj.tag)];
|
obj.tag)];
|
||||||
if (app.templateSettings.reverse) {
|
if (app.clientReverseTags) {
|
||||||
if (app.templateSettings.reverse.bridges) {
|
app.clientReverseTags.forEach(tag => {
|
||||||
this.inboundTags.push(...app.templateSettings.reverse.bridges.map(b => b.tag));
|
if (tag && !this.outboundTags.includes(tag)) {
|
||||||
}
|
this.outboundTags.push(tag);
|
||||||
if (app.templateSettings.reverse.portals) this.outboundTags.push(...app.templateSettings.reverse.portals.map(
|
}
|
||||||
b => b.tag));
|
});
|
||||||
}
|
}
|
||||||
this.balancerTags = [""];
|
this.balancerTags = [""];
|
||||||
if (app.templateSettings.routing && app.templateSettings.routing.balancers) {
|
if (app.templateSettings.routing && app.templateSettings.routing.balancers) {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
{{define "settings/xray/reverse"}}
|
|
||||||
<template v-if="reverseData.length > 0">
|
|
||||||
<a-space direction="vertical" size="middle">
|
|
||||||
<a-button type="primary" icon="plus" @click="addReverse()">
|
|
||||||
<span>{{ i18n "pages.xray.outbound.addReverse" }}</span>
|
|
||||||
</a-button>
|
|
||||||
<a-table :columns="reverseColumns" bordered :row-key="r => r.key" :data-source="reverseData"
|
|
||||||
:scroll="isMobile ? {} : { x: 200 }" :pagination="false" :indent-size="0"
|
|
||||||
:locale='{ filterConfirm: `{{ i18n "confirm" }}`, filterReset: `{{ i18n "reset" }}` }'>
|
|
||||||
<template slot="action" slot-scope="text, reverse, index">
|
|
||||||
<span>[[ index+1 ]]</span>
|
|
||||||
<a-dropdown :trigger="['click']">
|
|
||||||
<a-icon @click="e => e.preventDefault()" type="more"
|
|
||||||
:style="{ fontSize: '16px', textDecoration: 'bold' }"></a-icon>
|
|
||||||
<a-menu slot="overlay" :theme="themeSwitcher.currentTheme">
|
|
||||||
<a-menu-item @click="editReverse(index)">
|
|
||||||
<a-icon type="edit"></a-icon>
|
|
||||||
<span>{{ i18n "edit" }}</span>
|
|
||||||
</a-menu-item>
|
|
||||||
<a-menu-item @click="deleteReverse(index)">
|
|
||||||
<span :style="{ color: '#FF4D4F' }">
|
|
||||||
<a-icon type="delete"></a-icon>
|
|
||||||
<span>{{ i18n "delete"}}</span>
|
|
||||||
</span>
|
|
||||||
</a-menu-item>
|
|
||||||
</a-menu>
|
|
||||||
</a-dropdown>
|
|
||||||
</template>
|
|
||||||
</a-table>
|
|
||||||
</a-space>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<a-empty description='{{ i18n "emptyReverseDesc" }}' :style="{ margin: '10px' }">
|
|
||||||
<a-button type="primary" icon="plus" @click="addReverse()" :style="{ marginTop: '10px' }">
|
|
||||||
{{ i18n "pages.xray.outbound.addReverse" }}
|
|
||||||
</a-button>
|
|
||||||
</a-empty>
|
|
||||||
</template>
|
|
||||||
{{end}}
|
|
||||||
|
|
@ -81,13 +81,6 @@
|
||||||
</template>
|
</template>
|
||||||
{{ template "settings/xray/outbounds" . }}
|
{{ template "settings/xray/outbounds" . }}
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
<a-tab-pane key="tpl-reverse" :style="{ paddingTop: '20px' }" force-render="true">
|
|
||||||
<template #tab>
|
|
||||||
<a-icon type="import"></a-icon>
|
|
||||||
<span>{{ i18n "pages.xray.outbound.reverse"}}</span>
|
|
||||||
</template>
|
|
||||||
{{ template "settings/xray/reverse" . }}
|
|
||||||
</a-tab-pane>
|
|
||||||
<a-tab-pane key="tpl-balancer" :style="{ paddingTop: '20px' }" force-render="true">
|
<a-tab-pane key="tpl-balancer" :style="{ paddingTop: '20px' }" force-render="true">
|
||||||
<template #tab>
|
<template #tab>
|
||||||
<a-icon type="cluster"></a-icon>
|
<a-icon type="cluster"></a-icon>
|
||||||
|
|
@ -135,7 +128,6 @@
|
||||||
{{template "component/aSettingListItem" .}}
|
{{template "component/aSettingListItem" .}}
|
||||||
{{template "modals/ruleModal" .}}
|
{{template "modals/ruleModal" .}}
|
||||||
{{template "modals/outModal" .}}
|
{{template "modals/outModal" .}}
|
||||||
{{template "modals/reverseModal" .}}
|
|
||||||
{{template "modals/balancerModal" .}}
|
{{template "modals/balancerModal" .}}
|
||||||
{{template "modals/dnsModal" .}}
|
{{template "modals/dnsModal" .}}
|
||||||
{{template "modals/dnsPresetsModal" .}}
|
{{template "modals/dnsPresetsModal" .}}
|
||||||
|
|
@ -177,41 +169,13 @@
|
||||||
// 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' } },
|
||||||
];
|
];
|
||||||
|
|
||||||
const reverseColumns = [{
|
|
||||||
title: "#",
|
|
||||||
align: 'center',
|
|
||||||
width: 20,
|
|
||||||
scopedSlots: {
|
|
||||||
customRender: 'action'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '{{ i18n "pages.xray.outbound.type"}}',
|
|
||||||
dataIndex: 'type',
|
|
||||||
align: 'center',
|
|
||||||
width: 50
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '{{ i18n "pages.xray.outbound.tag"}}',
|
|
||||||
dataIndex: 'tag',
|
|
||||||
align: 'center',
|
|
||||||
width: 50
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '{{ i18n "pages.xray.outbound.domain"}}',
|
|
||||||
dataIndex: 'domain',
|
|
||||||
align: 'center',
|
|
||||||
width: 50
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const balancerColumns = [{
|
const balancerColumns = [{
|
||||||
title: "#",
|
title: "#",
|
||||||
align: 'center',
|
align: 'center',
|
||||||
|
|
@ -316,6 +280,7 @@
|
||||||
outboundTestUrl: 'https://www.google.com/generate_204',
|
outboundTestUrl: 'https://www.google.com/generate_204',
|
||||||
oldOutboundTestUrl: 'https://www.google.com/generate_204',
|
oldOutboundTestUrl: 'https://www.google.com/generate_204',
|
||||||
inboundTags: [],
|
inboundTags: [],
|
||||||
|
clientReverseTags: [],
|
||||||
outboundsTraffic: [],
|
outboundsTraffic: [],
|
||||||
outboundTestStates: {}, // Track testing state and results for each outbound
|
outboundTestStates: {}, // Track testing state and results for each outbound
|
||||||
saveBtnDisable: true,
|
saveBtnDisable: true,
|
||||||
|
|
@ -595,6 +560,7 @@
|
||||||
this.oldXraySetting = xs;
|
this.oldXraySetting = xs;
|
||||||
this.xraySetting = xs;
|
this.xraySetting = xs;
|
||||||
this.inboundTags = result.inboundTags;
|
this.inboundTags = result.inboundTags;
|
||||||
|
this.clientReverseTags = result.clientReverseTags || [];
|
||||||
this.outboundTestUrl = result.outboundTestUrl || 'https://www.google.com/generate_204';
|
this.outboundTestUrl = result.outboundTestUrl || 'https://www.google.com/generate_204';
|
||||||
this.oldOutboundTestUrl = this.outboundTestUrl;
|
this.oldOutboundTestUrl = this.outboundTestUrl;
|
||||||
this.saveBtnDisable = true;
|
this.saveBtnDisable = true;
|
||||||
|
|
@ -945,110 +911,6 @@
|
||||||
Vue.prototype.$message.error('{{ i18n "pages.xray.outbound.testError" }}: ' + error.message);
|
Vue.prototype.$message.error('{{ i18n "pages.xray.outbound.testError" }}: ' + error.message);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
addReverse() {
|
|
||||||
reverseModal.show({
|
|
||||||
title: '{{ i18n "pages.xray.outbound.addReverse"}}',
|
|
||||||
okText: '{{ i18n "pages.xray.outbound.addReverse" }}',
|
|
||||||
confirm: (reverse, rules) => {
|
|
||||||
reverseModal.loading();
|
|
||||||
if (reverse.tag.length > 0) {
|
|
||||||
newTemplateSettings = this.templateSettings;
|
|
||||||
if (newTemplateSettings.reverse == undefined) newTemplateSettings.reverse = {};
|
|
||||||
if (newTemplateSettings.reverse[reverse.type + 's'] == undefined) newTemplateSettings.reverse[
|
|
||||||
reverse.type + 's'] = [];
|
|
||||||
newTemplateSettings.reverse[reverse.type + 's'].push({
|
|
||||||
tag: reverse.tag,
|
|
||||||
domain: reverse.domain
|
|
||||||
});
|
|
||||||
this.templateSettings = newTemplateSettings;
|
|
||||||
|
|
||||||
// Add related rules
|
|
||||||
this.templateSettings.routing.rules.push(...rules);
|
|
||||||
this.routingRuleSettings = JSON.stringify(this.templateSettings.routing.rules);
|
|
||||||
}
|
|
||||||
reverseModal.close();
|
|
||||||
},
|
|
||||||
isEdit: false
|
|
||||||
});
|
|
||||||
},
|
|
||||||
editReverse(index) {
|
|
||||||
if (this.reverseData[index].type == "bridge") {
|
|
||||||
oldRules = this.templateSettings.routing.rules.filter(r => r.inboundTag && r.inboundTag[0] == this
|
|
||||||
.reverseData[index].tag);
|
|
||||||
} else {
|
|
||||||
oldRules = this.templateSettings.routing.rules.filter(r => r.outboundTag && r.outboundTag == this
|
|
||||||
.reverseData[index].tag);
|
|
||||||
}
|
|
||||||
reverseModal.show({
|
|
||||||
title: '{{ i18n "pages.xray.outbound.editReverse"}} ' + (index + 1),
|
|
||||||
reverse: this.reverseData[index],
|
|
||||||
rules: oldRules,
|
|
||||||
confirm: (reverse, rules) => {
|
|
||||||
reverseModal.loading();
|
|
||||||
if (reverse.tag.length > 0) {
|
|
||||||
oldData = this.reverseData[index];
|
|
||||||
newTemplateSettings = this.templateSettings;
|
|
||||||
oldReverseIndex = newTemplateSettings.reverse[oldData.type + 's'].findIndex(rs => rs.tag ==
|
|
||||||
oldData.tag);
|
|
||||||
oldRuleIndex0 = oldRules.length > 0 ? newTemplateSettings.routing.rules.findIndex(r => JSON
|
|
||||||
.stringify(r) == JSON.stringify(oldRules[0])) : -1;
|
|
||||||
oldRuleIndex1 = oldRules.length == 2 ? newTemplateSettings.routing.rules.findIndex(r => JSON
|
|
||||||
.stringify(r) == JSON.stringify(oldRules[1])) : -1;
|
|
||||||
if (oldData.type == reverse.type) {
|
|
||||||
newTemplateSettings.reverse[oldData.type + 's'][oldReverseIndex] = {
|
|
||||||
tag: reverse.tag,
|
|
||||||
domain: reverse.domain
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
newTemplateSettings.reverse[oldData.type + 's'].splice(oldReverseIndex, 1);
|
|
||||||
// delete empty object
|
|
||||||
if (newTemplateSettings.reverse[oldData.type + 's'].length == 0) Reflect.deleteProperty(
|
|
||||||
newTemplateSettings.reverse, oldData.type + 's');
|
|
||||||
// add other type of reverse if it is not exist
|
|
||||||
if (!newTemplateSettings.reverse[reverse.type + 's']) newTemplateSettings.reverse[reverse
|
|
||||||
.type + 's'] = [];
|
|
||||||
newTemplateSettings.reverse[reverse.type + 's'].push({
|
|
||||||
tag: reverse.tag,
|
|
||||||
domain: reverse.domain
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.templateSettings = newTemplateSettings;
|
|
||||||
|
|
||||||
// Adjust Rules
|
|
||||||
newRules = this.templateSettings.routing.rules;
|
|
||||||
oldRuleIndex0 != -1 ? newRules[oldRuleIndex0] = rules[0] : newRules.push(rules[0]);
|
|
||||||
oldRuleIndex1 != -1 ? newRules[oldRuleIndex1] = rules[1] : newRules.push(rules[1]);
|
|
||||||
this.routingRuleSettings = JSON.stringify(newRules);
|
|
||||||
}
|
|
||||||
reverseModal.close();
|
|
||||||
},
|
|
||||||
isEdit: true
|
|
||||||
});
|
|
||||||
},
|
|
||||||
deleteReverse(index) {
|
|
||||||
oldData = this.reverseData[index];
|
|
||||||
newTemplateSettings = this.templateSettings;
|
|
||||||
reverseTypeObj = newTemplateSettings.reverse[oldData.type + 's'];
|
|
||||||
realIndex = reverseTypeObj.findIndex(r => r.tag == oldData.tag && r.domain == oldData.domain);
|
|
||||||
newTemplateSettings.reverse[oldData.type + 's'].splice(realIndex, 1);
|
|
||||||
|
|
||||||
// delete empty objects
|
|
||||||
if (reverseTypeObj.length == 0) Reflect.deleteProperty(newTemplateSettings.reverse, oldData.type + 's');
|
|
||||||
if (Object.keys(newTemplateSettings.reverse).length === 0) Reflect.deleteProperty(newTemplateSettings,
|
|
||||||
'reverse');
|
|
||||||
|
|
||||||
// delete related routing rules
|
|
||||||
newRules = newTemplateSettings.routing.rules;
|
|
||||||
if (oldData.type == "bridge") {
|
|
||||||
newRules = newTemplateSettings.routing.rules.filter(r => !(r.inboundTag && r.inboundTag.length == 1 && r
|
|
||||||
.inboundTag[0] == oldData.tag));
|
|
||||||
} else if (oldData.type == "portal") {
|
|
||||||
newRules = newTemplateSettings.routing.rules.filter(r => r.outboundTag != oldData.tag);
|
|
||||||
}
|
|
||||||
newTemplateSettings.routing.rules = newRules;
|
|
||||||
|
|
||||||
this.templateSettings = newTemplateSettings;
|
|
||||||
},
|
|
||||||
async refreshOutboundTraffic() {
|
async refreshOutboundTraffic() {
|
||||||
if (!this.refreshing) {
|
if (!this.refreshing) {
|
||||||
this.refreshing = true;
|
this.refreshing = true;
|
||||||
|
|
@ -1457,32 +1319,6 @@
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
reverseData: {
|
|
||||||
get: function() {
|
|
||||||
data = []
|
|
||||||
if (this.templateSettings != null && this.templateSettings.reverse != null) {
|
|
||||||
if (this.templateSettings.reverse.bridges) {
|
|
||||||
this.templateSettings.reverse.bridges.forEach((o, index) => {
|
|
||||||
data.push({
|
|
||||||
'key': index,
|
|
||||||
'type': 'bridge',
|
|
||||||
...o
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (this.templateSettings.reverse.portals) {
|
|
||||||
this.templateSettings.reverse.portals.forEach((o, index) => {
|
|
||||||
data.push({
|
|
||||||
'key': index,
|
|
||||||
'type': 'portal',
|
|
||||||
...o
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
routingRuleSettings: {
|
routingRuleSettings: {
|
||||||
get: function() {
|
get: function() {
|
||||||
return this.templateSettings ? JSON.stringify(this.templateSettings.routing.rules, null, 2) : null;
|
return this.templateSettings ? JSON.stringify(this.templateSettings.routing.rules, null, 2) : null;
|
||||||
|
|
@ -2156,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;
|
||||||
|
|
@ -2437,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" .}}
|
||||||
|
|
@ -104,36 +104,6 @@ func (s *InboundService) GetInboundsByTrafficReset(period string) ([]*model.Inbo
|
||||||
return inbounds, nil
|
return inbounds, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *InboundService) checkPortExist(listen string, port int, ignoreId int) (bool, error) {
|
|
||||||
db := database.GetDB()
|
|
||||||
if listen == "" || listen == "0.0.0.0" || listen == "::" || listen == "::0" {
|
|
||||||
db = db.Model(model.Inbound{}).Where("port = ?", port)
|
|
||||||
} else {
|
|
||||||
db = db.Model(model.Inbound{}).
|
|
||||||
Where("port = ?", port).
|
|
||||||
Where(
|
|
||||||
db.Model(model.Inbound{}).Where(
|
|
||||||
"listen = ?", listen,
|
|
||||||
).Or(
|
|
||||||
"listen = \"\"",
|
|
||||||
).Or(
|
|
||||||
"listen = \"0.0.0.0\"",
|
|
||||||
).Or(
|
|
||||||
"listen = \"::\"",
|
|
||||||
).Or(
|
|
||||||
"listen = \"::0\""))
|
|
||||||
}
|
|
||||||
if ignoreId > 0 {
|
|
||||||
db = db.Where("id != ?", ignoreId)
|
|
||||||
}
|
|
||||||
var count int64
|
|
||||||
err := db.Count(&count).Error
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return count > 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *InboundService) GetClients(inbound *model.Inbound) ([]model.Client, error) {
|
func (s *InboundService) GetClients(inbound *model.Inbound) ([]model.Client, error) {
|
||||||
settings := map[string][]model.Client{}
|
settings := map[string][]model.Client{}
|
||||||
json.Unmarshal([]byte(inbound.Settings), &settings)
|
json.Unmarshal([]byte(inbound.Settings), &settings)
|
||||||
|
|
@ -221,7 +191,7 @@ func (s *InboundService) checkEmailExistForInbound(inbound *model.Inbound) (stri
|
||||||
// then saves the inbound to the database and optionally adds it to the running Xray instance.
|
// then saves the inbound to the database and optionally adds it to the running Xray instance.
|
||||||
// Returns the created inbound, whether Xray needs restart, and any error.
|
// Returns the created inbound, whether Xray needs restart, and any error.
|
||||||
func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
|
func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
|
||||||
exist, err := s.checkPortExist(inbound.Listen, inbound.Port, 0)
|
exist, err := s.checkPortConflict(inbound, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return inbound, false, err
|
return inbound, false, err
|
||||||
}
|
}
|
||||||
|
|
@ -229,6 +199,16 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
|
||||||
return inbound, false, common.NewError("Port already exists:", inbound.Port)
|
return inbound, false, common.NewError("Port already exists:", inbound.Port)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pick a tag that won't collide with an existing row. for the common
|
||||||
|
// case this is the same "inbound-<port>" string the controller already
|
||||||
|
// set; only when this port already has another inbound on a different
|
||||||
|
// transport (now possible after the transport-aware port check) does
|
||||||
|
// this disambiguate with a -tcp/-udp suffix. see #4103.
|
||||||
|
inbound.Tag, err = s.generateInboundTag(inbound, 0)
|
||||||
|
if err != nil {
|
||||||
|
return inbound, false, err
|
||||||
|
}
|
||||||
|
|
||||||
existEmail, err := s.checkEmailExistForInbound(inbound)
|
existEmail, err := s.checkEmailExistForInbound(inbound)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return inbound, false, err
|
return inbound, false, err
|
||||||
|
|
@ -462,7 +442,7 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
|
||||||
// It validates changes, updates the database, and syncs with the running Xray instance.
|
// It validates changes, updates the database, and syncs with the running Xray instance.
|
||||||
// Returns the updated inbound, whether Xray needs restart, and any error.
|
// Returns the updated inbound, whether Xray needs restart, and any error.
|
||||||
func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
|
func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
|
||||||
exist, err := s.checkPortExist(inbound.Listen, inbound.Port, inbound.Id)
|
exist, err := s.checkPortConflict(inbound, inbound.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return inbound, false, err
|
return inbound, false, err
|
||||||
}
|
}
|
||||||
|
|
@ -565,10 +545,11 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||||
oldInbound.Settings = inbound.Settings
|
oldInbound.Settings = inbound.Settings
|
||||||
oldInbound.StreamSettings = inbound.StreamSettings
|
oldInbound.StreamSettings = inbound.StreamSettings
|
||||||
oldInbound.Sniffing = inbound.Sniffing
|
oldInbound.Sniffing = inbound.Sniffing
|
||||||
if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" {
|
// regenerate tag with collision-aware logic. for this row we pass
|
||||||
oldInbound.Tag = fmt.Sprintf("inbound-%v", inbound.Port)
|
// inbound.Id as ignoreId so it doesn't see its own old tag in the db.
|
||||||
} else {
|
oldInbound.Tag, err = s.generateInboundTag(inbound, inbound.Id)
|
||||||
oldInbound.Tag = fmt.Sprintf("inbound-%v:%v", inbound.Listen, inbound.Port)
|
if err != nil {
|
||||||
|
return inbound, false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
needRestart := false
|
needRestart := false
|
||||||
|
|
@ -1764,6 +1745,51 @@ func (s *InboundService) GetInboundTags() (string, error) {
|
||||||
return string(tags), nil
|
return string(tags), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *InboundService) GetClientReverseTags() (string, error) {
|
||||||
|
db := database.GetDB()
|
||||||
|
var inbounds []model.Inbound
|
||||||
|
err := db.Model(model.Inbound{}).Select("settings").Where("protocol = ?", "vless").Find(&inbounds).Error
|
||||||
|
if err != nil && err != gorm.ErrRecordNotFound {
|
||||||
|
return "[]", err
|
||||||
|
}
|
||||||
|
|
||||||
|
tagSet := make(map[string]struct{})
|
||||||
|
for _, inbound := range inbounds {
|
||||||
|
var settings map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
clients, ok := settings["clients"].([]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, client := range clients {
|
||||||
|
clientMap, ok := client.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
reverse, ok := clientMap["reverse"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tag, _ := reverse["tag"].(string)
|
||||||
|
tag = strings.TrimSpace(tag)
|
||||||
|
if tag != "" {
|
||||||
|
tagSet[tag] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rawTags := make([]string, 0, len(tagSet))
|
||||||
|
for tag := range tagSet {
|
||||||
|
rawTags = append(rawTags, tag)
|
||||||
|
}
|
||||||
|
sort.Strings(rawTags)
|
||||||
|
|
||||||
|
result, _ := json.Marshal(rawTags)
|
||||||
|
return string(result), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *InboundService) MigrationRemoveOrphanedTraffics() {
|
func (s *InboundService) MigrationRemoveOrphanedTraffics() {
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
db.Exec(`
|
db.Exec(`
|
||||||
|
|
|
||||||
234
web/service/port_conflict.go
Normal file
234
web/service/port_conflict.go
Normal file
|
|
@ -0,0 +1,234 @@
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/database"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/util/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
// transportBits is a bitmask of L4 transports an inbound listens on.
|
||||||
|
// 0.0.0.0:443/tcp and 0.0.0.0:443/udp are independent sockets in linux,
|
||||||
|
// so the conflict check needs more than just the port number.
|
||||||
|
type transportBits uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
transportTCP transportBits = 1 << iota
|
||||||
|
transportUDP
|
||||||
|
)
|
||||||
|
|
||||||
|
// conflicts is true when the two masks share any L4 transport.
|
||||||
|
func (b transportBits) conflicts(o transportBits) bool { return b&o != 0 }
|
||||||
|
|
||||||
|
// inboundTransports returns the L4 transports the given inbound listens on.
|
||||||
|
// always returns at least one bit (falls back to tcp on parse errors), so
|
||||||
|
// the validator never gets looser than the old port-only check.
|
||||||
|
//
|
||||||
|
// the rules:
|
||||||
|
// - hysteria, hysteria2, wireguard: udp regardless of streamSettings
|
||||||
|
// - streamSettings.network=kcp: udp
|
||||||
|
// - shadowsocks: whatever settings.network says ("tcp" / "udp" / "tcp,udp")
|
||||||
|
// - mixed (socks/http combo): tcp + udp when settings.udp is true
|
||||||
|
// - everything else: tcp
|
||||||
|
func inboundTransports(protocol model.Protocol, streamSettings, settings string) transportBits {
|
||||||
|
// protocols that ignore streamSettings entirely.
|
||||||
|
switch protocol {
|
||||||
|
case model.Hysteria, model.Hysteria2, model.WireGuard:
|
||||||
|
return transportUDP
|
||||||
|
}
|
||||||
|
|
||||||
|
var bits transportBits
|
||||||
|
|
||||||
|
// peek at streamSettings.network to spot udp transports like kcp.
|
||||||
|
// parse errors are non-fatal: missing or weird streamSettings just
|
||||||
|
// keeps the default tcp bit below.
|
||||||
|
network := ""
|
||||||
|
if streamSettings != "" {
|
||||||
|
var ss map[string]any
|
||||||
|
if json.Unmarshal([]byte(streamSettings), &ss) == nil {
|
||||||
|
if n, _ := ss["network"].(string); n != "" {
|
||||||
|
network = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if network == "kcp" {
|
||||||
|
bits |= transportUDP
|
||||||
|
} else {
|
||||||
|
bits |= transportTCP
|
||||||
|
}
|
||||||
|
|
||||||
|
// some protocols also listen on udp on the same port via their own
|
||||||
|
// settings json. parse and merge.
|
||||||
|
if settings != "" {
|
||||||
|
var st map[string]any
|
||||||
|
if json.Unmarshal([]byte(settings), &st) == nil {
|
||||||
|
switch protocol {
|
||||||
|
case model.Shadowsocks:
|
||||||
|
// shadowsocks settings.network controls both tcp and udp,
|
||||||
|
// independently of streamSettings. the field takes "tcp",
|
||||||
|
// "udp", or "tcp,udp". if it's set, it wins outright.
|
||||||
|
if n, ok := st["network"].(string); ok && n != "" {
|
||||||
|
bits = 0
|
||||||
|
for _, part := range strings.Split(n, ",") {
|
||||||
|
switch strings.TrimSpace(part) {
|
||||||
|
case "tcp":
|
||||||
|
bits |= transportTCP
|
||||||
|
case "udp":
|
||||||
|
bits |= transportUDP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case model.Mixed:
|
||||||
|
// socks/http "mixed" inbound: settings.udp=true means it
|
||||||
|
// also relays udp on the same port (socks5 udp associate).
|
||||||
|
if udpOn, _ := st["udp"].(bool); udpOn {
|
||||||
|
bits |= transportUDP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// safety net: never return zero, even if every parse failed.
|
||||||
|
if bits == 0 {
|
||||||
|
bits = transportTCP
|
||||||
|
}
|
||||||
|
return bits
|
||||||
|
}
|
||||||
|
|
||||||
|
// listenOverlaps reports whether two listen addresses can collide on the
|
||||||
|
// same port. preserves the rule from the original checkPortExist:
|
||||||
|
// any-address (empty / 0.0.0.0 / :: / ::0) overlaps with everything,
|
||||||
|
// otherwise only identical specific addresses overlap.
|
||||||
|
func listenOverlaps(a, b string) bool {
|
||||||
|
if isAnyListen(a) || isAnyListen(b) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return a == b
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAnyListen(s string) bool {
|
||||||
|
return s == "" || s == "0.0.0.0" || s == "::" || s == "::0"
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkPortConflict reports whether adding/updating an inbound on
|
||||||
|
// (listen, port) would clash with an existing inbound. unlike the old
|
||||||
|
// port-only check, this one understands that tcp/443 and udp/443 are
|
||||||
|
// independent sockets in linux and may coexist on the same address.
|
||||||
|
//
|
||||||
|
// the listen-overlap rule (specific addr conflicts with any-addr on the
|
||||||
|
// same port, both directions) is preserved from the previous check.
|
||||||
|
func (s *InboundService) checkPortConflict(inbound *model.Inbound, ignoreId int) (bool, error) {
|
||||||
|
db := database.GetDB()
|
||||||
|
|
||||||
|
// pull every candidate on this port; we filter by listen-overlap and
|
||||||
|
// transport in go to keep the sql plain. the port column is indexed
|
||||||
|
// in practice by the existing port check, and the candidate set is
|
||||||
|
// tiny (one per coexisting socket family at most).
|
||||||
|
var candidates []*model.Inbound
|
||||||
|
q := db.Model(model.Inbound{}).Where("port = ?", inbound.Port)
|
||||||
|
if ignoreId > 0 {
|
||||||
|
q = q.Where("id != ?", ignoreId)
|
||||||
|
}
|
||||||
|
if err := q.Find(&candidates).Error; err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
newBits := inboundTransports(inbound.Protocol, inbound.StreamSettings, inbound.Settings)
|
||||||
|
for _, c := range candidates {
|
||||||
|
if !listenOverlaps(c.Listen, inbound.Listen) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inboundTransports(c.Protocol, c.StreamSettings, c.Settings).conflicts(newBits) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// baseInboundTag is the historical "inbound-<port>" / "inbound-<listen>:<port>"
|
||||||
|
// shape. kept exactly so existing routing rules that reference these tags
|
||||||
|
// keep working after the upgrade.
|
||||||
|
func baseInboundTag(listen string, port int) string {
|
||||||
|
if isAnyListen(listen) {
|
||||||
|
return fmt.Sprintf("inbound-%v", port)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("inbound-%v:%v", listen, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
// transportTagSuffix turns a transport mask into a short, stable string
|
||||||
|
// for tag disambiguation. only used when the base "inbound-<port>" is
|
||||||
|
// already taken on a coexisting transport (e.g. tcp inbound already lives
|
||||||
|
// on 443 and we're now adding a udp one).
|
||||||
|
func transportTagSuffix(b transportBits) string {
|
||||||
|
switch b {
|
||||||
|
case transportTCP:
|
||||||
|
return "tcp"
|
||||||
|
case transportUDP:
|
||||||
|
return "udp"
|
||||||
|
case transportTCP | transportUDP:
|
||||||
|
return "mixed"
|
||||||
|
}
|
||||||
|
return "any"
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateInboundTag picks a tag for the inbound that doesn't collide with
|
||||||
|
// any existing row. for the common single-inbound-per-port case the tag
|
||||||
|
// stays exactly as before ("inbound-443"), so user routing rules don't
|
||||||
|
// silently change shape on upgrade. only when a same-port neighbour
|
||||||
|
// already owns the base tag (now possible because tcp/443 and udp/443 can
|
||||||
|
// coexist after the transport-aware port check) does this append a
|
||||||
|
// transport suffix like "inbound-443-udp".
|
||||||
|
//
|
||||||
|
// ignoreId is the inbound's own id during update so it doesn't see itself
|
||||||
|
// as a collision; pass 0 on add.
|
||||||
|
func (s *InboundService) generateInboundTag(inbound *model.Inbound, ignoreId int) (string, error) {
|
||||||
|
base := baseInboundTag(inbound.Listen, inbound.Port)
|
||||||
|
exists, err := s.tagExists(base, ignoreId)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return base, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
suffix := transportTagSuffix(inboundTransports(inbound.Protocol, inbound.StreamSettings, inbound.Settings))
|
||||||
|
candidate := base + "-" + suffix
|
||||||
|
exists, err = s.tagExists(candidate, ignoreId)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return candidate, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// the transport-aware port check should have already blocked this
|
||||||
|
// path, but guard anyway so a unique-constraint failure doesn't reach
|
||||||
|
// the user as an opaque sqlite error.
|
||||||
|
for i := 2; i < 100; i++ {
|
||||||
|
c := fmt.Sprintf("%s-%d", candidate, i)
|
||||||
|
exists, err = s.tagExists(c, ignoreId)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", common.NewError("could not pick a unique inbound tag for port:", inbound.Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InboundService) tagExists(tag string, ignoreId int) (bool, error) {
|
||||||
|
db := database.GetDB()
|
||||||
|
q := db.Model(model.Inbound{}).Where("tag = ?", tag)
|
||||||
|
if ignoreId > 0 {
|
||||||
|
q = q.Where("id != ?", ignoreId)
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
if err := q.Count(&count).Error; err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return count > 0, nil
|
||||||
|
}
|
||||||
363
web/service/port_conflict_test.go
Normal file
363
web/service/port_conflict_test.go
Normal file
|
|
@ -0,0 +1,363 @@
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/database"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||||
|
xuilogger "github.com/mhsanaei/3x-ui/v2/logger"
|
||||||
|
"github.com/op/go-logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
// the panel logger is a process-wide singleton. init it once per test
|
||||||
|
// binary so a stray warning from gorm doesn't blow up on a nil logger.
|
||||||
|
var portConflictLoggerOnce sync.Once
|
||||||
|
|
||||||
|
// setupConflictDB wires a temp sqlite db so checkPortConflict can read
|
||||||
|
// real candidates. closes the db before t.TempDir cleans up so windows
|
||||||
|
// doesn't refuse to remove the file.
|
||||||
|
func setupConflictDB(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
portConflictLoggerOnce.Do(func() { xuilogger.InitLogger(logging.ERROR) })
|
||||||
|
|
||||||
|
dbDir := t.TempDir()
|
||||||
|
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||||
|
if err := database.InitDB(filepath.Join(dbDir, "3x-ui.db")); err != nil {
|
||||||
|
t.Fatalf("InitDB: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := database.CloseDB(); err != nil {
|
||||||
|
t.Logf("CloseDB warning: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedInboundConflict(t *testing.T, tag, listen string, port int, protocol model.Protocol, streamSettings, settings string) {
|
||||||
|
t.Helper()
|
||||||
|
in := &model.Inbound{
|
||||||
|
Tag: tag,
|
||||||
|
Enable: true,
|
||||||
|
Listen: listen,
|
||||||
|
Port: port,
|
||||||
|
Protocol: protocol,
|
||||||
|
StreamSettings: streamSettings,
|
||||||
|
Settings: settings,
|
||||||
|
}
|
||||||
|
if err := database.GetDB().Create(in).Error; err != nil {
|
||||||
|
t.Fatalf("seed inbound %s: %v", tag, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInboundTransports(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
protocol model.Protocol
|
||||||
|
streamSettings string
|
||||||
|
settings string
|
||||||
|
want transportBits
|
||||||
|
}{
|
||||||
|
{"vless default tcp", model.VLESS, `{"network":"tcp"}`, ``, transportTCP},
|
||||||
|
{"vless ws (still tcp)", model.VLESS, `{"network":"ws"}`, ``, transportTCP},
|
||||||
|
{"vless kcp is udp", model.VLESS, `{"network":"kcp"}`, ``, transportUDP},
|
||||||
|
{"vless empty stream defaults to tcp", model.VLESS, ``, ``, transportTCP},
|
||||||
|
{"vless garbage stream stays tcp", model.VLESS, `not json`, ``, transportTCP},
|
||||||
|
|
||||||
|
{"vmess default tcp", model.VMESS, `{"network":"tcp"}`, ``, transportTCP},
|
||||||
|
{"trojan grpc is tcp", model.Trojan, `{"network":"grpc"}`, ``, transportTCP},
|
||||||
|
|
||||||
|
{"hysteria forced udp", model.Hysteria, `{"network":"tcp"}`, ``, transportUDP},
|
||||||
|
{"hysteria2 forced udp", model.Hysteria2, ``, ``, transportUDP},
|
||||||
|
{"wireguard forced udp", model.WireGuard, ``, ``, transportUDP},
|
||||||
|
|
||||||
|
{"shadowsocks tcp,udp", model.Shadowsocks, ``, `{"network":"tcp,udp"}`, transportTCP | transportUDP},
|
||||||
|
{"shadowsocks udp only", model.Shadowsocks, ``, `{"network":"udp"}`, transportUDP},
|
||||||
|
{"shadowsocks tcp only", model.Shadowsocks, ``, `{"network":"tcp"}`, transportTCP},
|
||||||
|
{"shadowsocks empty network falls back to streamSettings", model.Shadowsocks, `{"network":"tcp"}`, `{}`, transportTCP},
|
||||||
|
|
||||||
|
{"mixed udp on", model.Mixed, `{"network":"tcp"}`, `{"udp":true}`, transportTCP | transportUDP},
|
||||||
|
{"mixed udp off", model.Mixed, `{"network":"tcp"}`, `{"udp":false}`, transportTCP},
|
||||||
|
{"mixed udp missing", model.Mixed, `{"network":"tcp"}`, `{}`, transportTCP},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
got := inboundTransports(c.protocol, c.streamSettings, c.settings)
|
||||||
|
if got != c.want {
|
||||||
|
t.Fatalf("got bits %#b, want %#b", got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListenOverlaps(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
a, b string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"", "", true},
|
||||||
|
{"0.0.0.0", "", true},
|
||||||
|
{"0.0.0.0", "1.2.3.4", true},
|
||||||
|
{"::", "1.2.3.4", true},
|
||||||
|
{"::0", "fe80::1", true},
|
||||||
|
{"1.2.3.4", "1.2.3.4", true},
|
||||||
|
{"1.2.3.4", "5.6.7.8", false},
|
||||||
|
{"1.2.3.4", "::1", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := listenOverlaps(c.a, c.b); got != c.want {
|
||||||
|
t.Errorf("listenOverlaps(%q, %q) = %v, want %v", c.a, c.b, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the actual case from #4103: tcp/443 vless reality and udp/443
|
||||||
|
// hysteria2 must be allowed to coexist on the same port.
|
||||||
|
func TestCheckPortConflict_TCPandUDPCoexistOnSamePort(t *testing.T) {
|
||||||
|
setupConflictDB(t)
|
||||||
|
seedInboundConflict(t, "vless-443-tcp", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
|
||||||
|
|
||||||
|
svc := &InboundService{}
|
||||||
|
hyst2 := &model.Inbound{
|
||||||
|
Tag: "hyst2-443-udp",
|
||||||
|
Listen: "0.0.0.0",
|
||||||
|
Port: 443,
|
||||||
|
Protocol: model.Hysteria2,
|
||||||
|
}
|
||||||
|
exist, err := svc.checkPortConflict(hyst2, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("checkPortConflict: %v", err)
|
||||||
|
}
|
||||||
|
if exist {
|
||||||
|
t.Fatalf("vless/tcp and hysteria2/udp on the same port must be allowed to coexist")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// two tcp inbounds on the same port still conflict.
|
||||||
|
func TestCheckPortConflict_TCPCollidesWithTCP(t *testing.T) {
|
||||||
|
setupConflictDB(t)
|
||||||
|
seedInboundConflict(t, "vless-443-a", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
|
||||||
|
|
||||||
|
svc := &InboundService{}
|
||||||
|
other := &model.Inbound{
|
||||||
|
Tag: "vless-443-b",
|
||||||
|
Listen: "0.0.0.0",
|
||||||
|
Port: 443,
|
||||||
|
Protocol: model.Trojan,
|
||||||
|
StreamSettings: `{"network":"ws"}`,
|
||||||
|
}
|
||||||
|
exist, err := svc.checkPortConflict(other, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("checkPortConflict: %v", err)
|
||||||
|
}
|
||||||
|
if !exist {
|
||||||
|
t.Fatalf("two tcp inbounds on the same port must still conflict")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// two udp inbounds (e.g. hysteria2 vs wireguard) on the same port also
|
||||||
|
// conflict, since they fight for the same socket.
|
||||||
|
func TestCheckPortConflict_UDPCollidesWithUDP(t *testing.T) {
|
||||||
|
setupConflictDB(t)
|
||||||
|
seedInboundConflict(t, "hyst2-443", "0.0.0.0", 443, model.Hysteria2, ``, ``)
|
||||||
|
|
||||||
|
svc := &InboundService{}
|
||||||
|
wg := &model.Inbound{
|
||||||
|
Tag: "wg-443",
|
||||||
|
Listen: "0.0.0.0",
|
||||||
|
Port: 443,
|
||||||
|
Protocol: model.WireGuard,
|
||||||
|
}
|
||||||
|
exist, err := svc.checkPortConflict(wg, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("checkPortConflict: %v", err)
|
||||||
|
}
|
||||||
|
if !exist {
|
||||||
|
t.Fatalf("two udp inbounds on the same port must conflict")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// shadowsocks listening on tcp+udp eats the whole port for both
|
||||||
|
// transports, so neither a tcp nor a udp neighbour is allowed.
|
||||||
|
func TestCheckPortConflict_ShadowsocksDualListenBlocksBoth(t *testing.T) {
|
||||||
|
setupConflictDB(t)
|
||||||
|
seedInboundConflict(t, "ss-443-dual", "0.0.0.0", 443, model.Shadowsocks, ``, `{"network":"tcp,udp"}`)
|
||||||
|
|
||||||
|
svc := &InboundService{}
|
||||||
|
|
||||||
|
tcpClash := &model.Inbound{
|
||||||
|
Tag: "vless-443",
|
||||||
|
Listen: "0.0.0.0",
|
||||||
|
Port: 443,
|
||||||
|
Protocol: model.VLESS,
|
||||||
|
StreamSettings: `{"network":"tcp"}`,
|
||||||
|
}
|
||||||
|
if exist, err := svc.checkPortConflict(tcpClash, 0); err != nil || !exist {
|
||||||
|
t.Fatalf("tcp inbound should clash with shadowsocks tcp,udp; exist=%v err=%v", exist, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
udpClash := &model.Inbound{
|
||||||
|
Tag: "hyst2-443",
|
||||||
|
Listen: "0.0.0.0",
|
||||||
|
Port: 443,
|
||||||
|
Protocol: model.Hysteria2,
|
||||||
|
}
|
||||||
|
if exist, err := svc.checkPortConflict(udpClash, 0); err != nil || !exist {
|
||||||
|
t.Fatalf("udp inbound should clash with shadowsocks tcp,udp; exist=%v err=%v", exist, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// different ports never conflict regardless of transport.
|
||||||
|
func TestCheckPortConflict_DifferentPortNeverConflicts(t *testing.T) {
|
||||||
|
setupConflictDB(t)
|
||||||
|
seedInboundConflict(t, "vless-443", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
|
||||||
|
|
||||||
|
svc := &InboundService{}
|
||||||
|
other := &model.Inbound{
|
||||||
|
Tag: "vless-444",
|
||||||
|
Listen: "0.0.0.0",
|
||||||
|
Port: 444,
|
||||||
|
Protocol: model.VLESS,
|
||||||
|
StreamSettings: `{"network":"tcp"}`,
|
||||||
|
}
|
||||||
|
if exist, err := svc.checkPortConflict(other, 0); err != nil || exist {
|
||||||
|
t.Fatalf("different port must not conflict; exist=%v err=%v", exist, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// specific listen addresses on the same port don't clash with each other,
|
||||||
|
// but do clash with any-address on the same port (preserved from the old
|
||||||
|
// check).
|
||||||
|
func TestCheckPortConflict_ListenOverlapPreserved(t *testing.T) {
|
||||||
|
setupConflictDB(t)
|
||||||
|
seedInboundConflict(t, "vless-1.2.3.4", "1.2.3.4", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
|
||||||
|
|
||||||
|
svc := &InboundService{}
|
||||||
|
|
||||||
|
// different specific address, same port + transport: no conflict.
|
||||||
|
other := &model.Inbound{
|
||||||
|
Tag: "vless-5.6.7.8",
|
||||||
|
Listen: "5.6.7.8",
|
||||||
|
Port: 443,
|
||||||
|
Protocol: model.VLESS,
|
||||||
|
StreamSettings: `{"network":"tcp"}`,
|
||||||
|
}
|
||||||
|
if exist, err := svc.checkPortConflict(other, 0); err != nil || exist {
|
||||||
|
t.Fatalf("different specific listen must not conflict; exist=%v err=%v", exist, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// any-address vs specific on same transport: conflict (any-addr wins).
|
||||||
|
anyAddr := &model.Inbound{
|
||||||
|
Tag: "vless-any",
|
||||||
|
Listen: "0.0.0.0",
|
||||||
|
Port: 443,
|
||||||
|
Protocol: model.VLESS,
|
||||||
|
StreamSettings: `{"network":"tcp"}`,
|
||||||
|
}
|
||||||
|
if exist, err := svc.checkPortConflict(anyAddr, 0); err != nil || !exist {
|
||||||
|
t.Fatalf("any-addr on same port+transport must conflict with specific; exist=%v err=%v", exist, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// when the base "inbound-<port>" tag is already taken on a coexisting
|
||||||
|
// transport, generateInboundTag must disambiguate with a transport
|
||||||
|
// suffix so the unique-tag DB constraint stays satisfied.
|
||||||
|
func TestGenerateInboundTag_DisambiguatesByTransportOnSamePort(t *testing.T) {
|
||||||
|
setupConflictDB(t)
|
||||||
|
// existing tcp inbound owns "inbound-443".
|
||||||
|
seedInboundConflict(t, "inbound-443", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
|
||||||
|
|
||||||
|
svc := &InboundService{}
|
||||||
|
udp := &model.Inbound{
|
||||||
|
Listen: "0.0.0.0",
|
||||||
|
Port: 443,
|
||||||
|
Protocol: model.Hysteria2,
|
||||||
|
}
|
||||||
|
got, err := svc.generateInboundTag(udp, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generateInboundTag: %v", err)
|
||||||
|
}
|
||||||
|
if got != "inbound-443-udp" {
|
||||||
|
t.Fatalf("expected disambiguated tag inbound-443-udp, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// when the port is free, the historical "inbound-<port>" shape is kept
|
||||||
|
// so existing routing rules don't change shape on upgrade.
|
||||||
|
func TestGenerateInboundTag_KeepsBaseTagWhenFree(t *testing.T) {
|
||||||
|
setupConflictDB(t)
|
||||||
|
|
||||||
|
svc := &InboundService{}
|
||||||
|
in := &model.Inbound{
|
||||||
|
Listen: "0.0.0.0",
|
||||||
|
Port: 8443,
|
||||||
|
Protocol: model.VLESS,
|
||||||
|
}
|
||||||
|
got, err := svc.generateInboundTag(in, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generateInboundTag: %v", err)
|
||||||
|
}
|
||||||
|
if got != "inbound-8443" {
|
||||||
|
t.Fatalf("expected inbound-8443, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// updating an inbound on its own port must not flag its own tag as
|
||||||
|
// taken, that's what ignoreId is for.
|
||||||
|
func TestGenerateInboundTag_IgnoresSelfOnUpdate(t *testing.T) {
|
||||||
|
setupConflictDB(t)
|
||||||
|
seedInboundConflict(t, "inbound-443", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
|
||||||
|
|
||||||
|
var existing model.Inbound
|
||||||
|
if err := database.GetDB().Where("tag = ?", "inbound-443").First(&existing).Error; err != nil {
|
||||||
|
t.Fatalf("read seeded row: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := &InboundService{}
|
||||||
|
got, err := svc.generateInboundTag(&existing, existing.Id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generateInboundTag: %v", err)
|
||||||
|
}
|
||||||
|
if got != "inbound-443" {
|
||||||
|
t.Fatalf("self-update must keep base tag, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// specific listen address gets the listen-prefixed shape and same
|
||||||
|
// disambiguation rules.
|
||||||
|
func TestGenerateInboundTag_SpecificListenSameDisambiguation(t *testing.T) {
|
||||||
|
setupConflictDB(t)
|
||||||
|
seedInboundConflict(t, "inbound-1.2.3.4:443", "1.2.3.4", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
|
||||||
|
|
||||||
|
svc := &InboundService{}
|
||||||
|
udp := &model.Inbound{
|
||||||
|
Listen: "1.2.3.4",
|
||||||
|
Port: 443,
|
||||||
|
Protocol: model.Hysteria2,
|
||||||
|
}
|
||||||
|
got, err := svc.generateInboundTag(udp, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generateInboundTag: %v", err)
|
||||||
|
}
|
||||||
|
if got != "inbound-1.2.3.4:443-udp" {
|
||||||
|
t.Fatalf("expected inbound-1.2.3.4:443-udp, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// updating an inbound must not see itself as a conflict, that's what
|
||||||
|
// ignoreId is for.
|
||||||
|
func TestCheckPortConflict_IgnoreSelfOnUpdate(t *testing.T) {
|
||||||
|
setupConflictDB(t)
|
||||||
|
seedInboundConflict(t, "vless-443", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
|
||||||
|
|
||||||
|
var existing model.Inbound
|
||||||
|
if err := database.GetDB().Where("tag = ?", "vless-443").First(&existing).Error; err != nil {
|
||||||
|
t.Fatalf("read seeded row: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := &InboundService{}
|
||||||
|
if exist, err := svc.checkPortConflict(&existing, existing.Id); err != nil || exist {
|
||||||
|
t.Fatalf("self-update must not be flagged as conflict; exist=%v err=%v", exist, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -555,6 +555,9 @@ func (s *ServerService) GetXrayVersions() ([]string, error) {
|
||||||
var versions []string
|
var versions []string
|
||||||
for _, release := range releases {
|
for _, release := range releases {
|
||||||
tagVersion := strings.TrimPrefix(release.TagName, "v")
|
tagVersion := strings.TrimPrefix(release.TagName, "v")
|
||||||
|
if tagVersion == "26.5.3" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
tagParts := strings.Split(tagVersion, ".")
|
tagParts := strings.Split(tagVersion, ".")
|
||||||
if len(tagParts) != 3 {
|
if len(tagParts) != 3 {
|
||||||
continue
|
continue
|
||||||
|
|
@ -567,7 +570,7 @@ func (s *ServerService) GetXrayVersions() ([]string, error) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if major > 26 || (major == 26 && minor > 3) || (major == 26 && minor == 3 && patch >= 10) {
|
if major > 26 || (major == 26 && minor > 4) || (major == 26 && minor == 4 && patch >= 25) {
|
||||||
versions = append(versions, release.TagName)
|
versions = append(versions, release.TagName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,7 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
|
||||||
|
|
||||||
// clear client config for additional parameters
|
// clear client config for additional parameters
|
||||||
for key := range c {
|
for key := range c {
|
||||||
if key != "email" && key != "id" && key != "password" && key != "flow" && key != "method" && key != "auth" {
|
if key != "email" && key != "id" && key != "password" && key != "flow" && key != "method" && key != "auth" && key != "reverse" {
|
||||||
delete(c, key)
|
delete(c, key)
|
||||||
}
|
}
|
||||||
if flow, ok := c["flow"].(string); ok && flow == "xtls-rprx-vision-udp443" {
|
if flow, ok := c["flow"].(string); ok && flow == "xtls-rprx-vision-udp443" {
|
||||||
|
|
|
||||||
|
|
@ -584,6 +584,9 @@
|
||||||
"addReverse" = "أضف عكسي"
|
"addReverse" = "أضف عكسي"
|
||||||
"editOutbound" = "عدل المخرج"
|
"editOutbound" = "عدل المخرج"
|
||||||
"editReverse" = "عدل العكسي"
|
"editReverse" = "عدل العكسي"
|
||||||
|
"reverseTag" = "وسم العكسي"
|
||||||
|
"reverseTagDesc" = "وسم الخروج لبروكسي VLESS العكسي البسيط. اتركه فارغاً لتعطيله."
|
||||||
|
"reverseTagPlaceholder" = "وسم الخروج (اتركه فارغاً للتعطيل)"
|
||||||
"tag" = "تاج"
|
"tag" = "تاج"
|
||||||
"tagDesc" = "تاج فريد"
|
"tagDesc" = "تاج فريد"
|
||||||
"address" = "العنوان"
|
"address" = "العنوان"
|
||||||
|
|
|
||||||
|
|
@ -584,6 +584,9 @@
|
||||||
"addReverse" = "Add Reverse"
|
"addReverse" = "Add Reverse"
|
||||||
"editOutbound" = "Edit Outbound"
|
"editOutbound" = "Edit Outbound"
|
||||||
"editReverse" = "Edit Reverse"
|
"editReverse" = "Edit Reverse"
|
||||||
|
"reverseTag" = "Reverse Tag"
|
||||||
|
"reverseTagDesc" = "VLESS simple reverse proxy tag. Leave empty to disable."
|
||||||
|
"reverseTagPlaceholder" = "reverse tag (leave empty to disable)"
|
||||||
"tag" = "Tag"
|
"tag" = "Tag"
|
||||||
"tagDesc" = "Unique Tag"
|
"tagDesc" = "Unique Tag"
|
||||||
"address" = "Address"
|
"address" = "Address"
|
||||||
|
|
|
||||||
|
|
@ -584,6 +584,9 @@
|
||||||
"addReverse" = "Agregar reverso"
|
"addReverse" = "Agregar reverso"
|
||||||
"editOutbound" = "Editar salida"
|
"editOutbound" = "Editar salida"
|
||||||
"editReverse" = "Editar reverso"
|
"editReverse" = "Editar reverso"
|
||||||
|
"reverseTag" = "Etiqueta Reverso"
|
||||||
|
"reverseTagDesc" = "Etiqueta de salida del proxy inverso simple VLESS. Dejar vacío para deshabilitar. Cuando se establece, las conexiones de este cliente pueden usarse como túnel de proxy inverso."
|
||||||
|
"reverseTagPlaceholder" = "etiqueta de salida (vacío para deshabilitar)"
|
||||||
"tag" = "Etiqueta"
|
"tag" = "Etiqueta"
|
||||||
"tagDesc" = "etiqueta única"
|
"tagDesc" = "etiqueta única"
|
||||||
"address" = "Dirección"
|
"address" = "Dirección"
|
||||||
|
|
|
||||||
|
|
@ -584,6 +584,9 @@
|
||||||
"addReverse" = "افزودن معکوس"
|
"addReverse" = "افزودن معکوس"
|
||||||
"editOutbound" = "ویرایش خروجی"
|
"editOutbound" = "ویرایش خروجی"
|
||||||
"editReverse" = "ویرایش معکوس"
|
"editReverse" = "ویرایش معکوس"
|
||||||
|
"reverseTag" = "تگ معکوس"
|
||||||
|
"reverseTagDesc" = "تگ خروجی پروکسی معکوس ساده VLESS. برای غیرفعال کردن خالی بگذارید. در صورت تنظیم، اتصالات این کلاینت میتوانند به عنوان تونل پروکسی معکوس استفاده شوند."
|
||||||
|
"reverseTagPlaceholder" = "تگ خروجی (خالی = غیرفعال)"
|
||||||
"tag" = "برچسب"
|
"tag" = "برچسب"
|
||||||
"tagDesc" = "برچسب یگانه"
|
"tagDesc" = "برچسب یگانه"
|
||||||
"address" = "آدرس"
|
"address" = "آدرس"
|
||||||
|
|
|
||||||
|
|
@ -584,6 +584,9 @@
|
||||||
"addReverse" = "Tambahkan Revers"
|
"addReverse" = "Tambahkan Revers"
|
||||||
"editOutbound" = "Edit Keluar"
|
"editOutbound" = "Edit Keluar"
|
||||||
"editReverse" = "Edit Revers"
|
"editReverse" = "Edit Revers"
|
||||||
|
"reverseTag" = "Tag Revers"
|
||||||
|
"reverseTagDesc" = "Tag outbound proxy revers sederhana VLESS. Kosongkan untuk menonaktifkan."
|
||||||
|
"reverseTagPlaceholder" = "tag outbound (kosong untuk menonaktifkan)"
|
||||||
"tag" = "Tag"
|
"tag" = "Tag"
|
||||||
"tagDesc" = "Tag Unik"
|
"tagDesc" = "Tag Unik"
|
||||||
"address" = "Alamat"
|
"address" = "Alamat"
|
||||||
|
|
|
||||||
|
|
@ -584,6 +584,9 @@
|
||||||
"addReverse" = "リバース追加"
|
"addReverse" = "リバース追加"
|
||||||
"editOutbound" = "アウトバウンド編集"
|
"editOutbound" = "アウトバウンド編集"
|
||||||
"editReverse" = "リバース編集"
|
"editReverse" = "リバース編集"
|
||||||
|
"reverseTag" = "リバースタグ"
|
||||||
|
"reverseTagDesc" = "VLESSシンプルリバースプロキシのアウトバウンドタグ。無効にするには空欄にしてください。"
|
||||||
|
"reverseTagPlaceholder" = "アウトバウンドタグ(空欄で無効)"
|
||||||
"tag" = "タグ"
|
"tag" = "タグ"
|
||||||
"tagDesc" = "一意のタグ"
|
"tagDesc" = "一意のタグ"
|
||||||
"address" = "アドレス"
|
"address" = "アドレス"
|
||||||
|
|
|
||||||
|
|
@ -584,6 +584,9 @@
|
||||||
"addReverse" = "Adicionar Reverso"
|
"addReverse" = "Adicionar Reverso"
|
||||||
"editOutbound" = "Editar Saída"
|
"editOutbound" = "Editar Saída"
|
||||||
"editReverse" = "Editar Reverso"
|
"editReverse" = "Editar Reverso"
|
||||||
|
"reverseTag" = "Tag de Reverso"
|
||||||
|
"reverseTagDesc" = "Tag de saída do proxy reverso simples VLESS. Deixe vazio para desabilitar."
|
||||||
|
"reverseTagPlaceholder" = "tag de saída (vazio para desabilitar)"
|
||||||
"tag" = "Tag"
|
"tag" = "Tag"
|
||||||
"tagDesc" = "Tag Única"
|
"tagDesc" = "Tag Única"
|
||||||
"address" = "Endereço"
|
"address" = "Endereço"
|
||||||
|
|
|
||||||
|
|
@ -584,6 +584,9 @@
|
||||||
"addReverse" = "Создать реверс-прокси"
|
"addReverse" = "Создать реверс-прокси"
|
||||||
"editOutbound" = "Изменить исходящее подключение"
|
"editOutbound" = "Изменить исходящее подключение"
|
||||||
"editReverse" = "Редактировать реверс-прокси"
|
"editReverse" = "Редактировать реверс-прокси"
|
||||||
|
"reverseTag" = "Тег реверс-прокси"
|
||||||
|
"reverseTagDesc" = "Тег исходящего подключения для простого реверс-прокси VLESS. Оставьте пустым для отключения."
|
||||||
|
"reverseTagPlaceholder" = "тег исходящего (пусто = отключено)"
|
||||||
"tag" = "Тег"
|
"tag" = "Тег"
|
||||||
"tagDesc" = "Уникальный тег"
|
"tagDesc" = "Уникальный тег"
|
||||||
"address" = "Адрес"
|
"address" = "Адрес"
|
||||||
|
|
|
||||||
|
|
@ -584,6 +584,9 @@
|
||||||
"addReverse" = "Ters Ekle"
|
"addReverse" = "Ters Ekle"
|
||||||
"editOutbound" = "Gideni Düzenle"
|
"editOutbound" = "Gideni Düzenle"
|
||||||
"editReverse" = "Tersi Düzenle"
|
"editReverse" = "Tersi Düzenle"
|
||||||
|
"reverseTag" = "Ters Etiket"
|
||||||
|
"reverseTagDesc" = "VLESS basit ters proxy çıkış etiketi. Devre dışı bırakmak için boş bırakın."
|
||||||
|
"reverseTagPlaceholder" = "çıkış etiketi (boş = devre dışı)"
|
||||||
"tag" = "Etiket"
|
"tag" = "Etiket"
|
||||||
"tagDesc" = "Benzersiz Etiket"
|
"tagDesc" = "Benzersiz Etiket"
|
||||||
"address" = "Adres"
|
"address" = "Adres"
|
||||||
|
|
|
||||||
|
|
@ -584,6 +584,9 @@
|
||||||
"addReverse" = "Додати реверс"
|
"addReverse" = "Додати реверс"
|
||||||
"editOutbound" = "Редагувати вихідні"
|
"editOutbound" = "Редагувати вихідні"
|
||||||
"editReverse" = "Редагувати реверс"
|
"editReverse" = "Редагувати реверс"
|
||||||
|
"reverseTag" = "Тег реверс-проксі"
|
||||||
|
"reverseTagDesc" = "Тег вихідного з'єднання для простого реверс-проксі VLESS. Залиште порожнім для вимкнення."
|
||||||
|
"reverseTagPlaceholder" = "тег вихідного (порожнє = вимкнено)"
|
||||||
"tag" = "Тег"
|
"tag" = "Тег"
|
||||||
"tagDesc" = "Унікальний тег"
|
"tagDesc" = "Унікальний тег"
|
||||||
"address" = "Адреса"
|
"address" = "Адреса"
|
||||||
|
|
|
||||||
|
|
@ -584,6 +584,9 @@
|
||||||
"addReverse" = "Thêm đảo ngược"
|
"addReverse" = "Thêm đảo ngược"
|
||||||
"editOutbound" = "Chỉnh sửa gửi đi"
|
"editOutbound" = "Chỉnh sửa gửi đi"
|
||||||
"editReverse" = "Chỉnh sửa ngược lại"
|
"editReverse" = "Chỉnh sửa ngược lại"
|
||||||
|
"reverseTag" = "Thẻ Ngược"
|
||||||
|
"reverseTagDesc" = "Thẻ outbound của proxy ngược đơn giản VLESS. Để trống để vô hiệu hóa."
|
||||||
|
"reverseTagPlaceholder" = "thẻ outbound (để trống để vô hiệu hóa)"
|
||||||
"tag" = "Thẻ"
|
"tag" = "Thẻ"
|
||||||
"tagDesc" = "thẻ duy nhất"
|
"tagDesc" = "thẻ duy nhất"
|
||||||
"address" = "Địa chỉ"
|
"address" = "Địa chỉ"
|
||||||
|
|
|
||||||
|
|
@ -584,6 +584,9 @@
|
||||||
"addReverse" = "添加反向"
|
"addReverse" = "添加反向"
|
||||||
"editOutbound" = "编辑出站"
|
"editOutbound" = "编辑出站"
|
||||||
"editReverse" = "编辑反向"
|
"editReverse" = "编辑反向"
|
||||||
|
"reverseTag" = "反向标签"
|
||||||
|
"reverseTagDesc" = "VLESS 简易反向代理出站标签。留空则禁用。设置后,此客户端的连接可用作反向代理隧道。"
|
||||||
|
"reverseTagPlaceholder" = "出站标签(留空则禁用)"
|
||||||
"tag" = "标签"
|
"tag" = "标签"
|
||||||
"tagDesc" = "唯一标签"
|
"tagDesc" = "唯一标签"
|
||||||
"address" = "地址"
|
"address" = "地址"
|
||||||
|
|
|
||||||
|
|
@ -584,6 +584,9 @@
|
||||||
"addReverse" = "新增反向"
|
"addReverse" = "新增反向"
|
||||||
"editOutbound" = "編輯出站"
|
"editOutbound" = "編輯出站"
|
||||||
"editReverse" = "編輯反向"
|
"editReverse" = "編輯反向"
|
||||||
|
"reverseTag" = "反向標籤"
|
||||||
|
"reverseTagDesc" = "VLESS 簡易反向代理出站標籤。留空則停用。設定後,此客戶端的連線可作為反向代理隧道。"
|
||||||
|
"reverseTagPlaceholder" = "出站標籤(留空則停用)"
|
||||||
"tag" = "標籤"
|
"tag" = "標籤"
|
||||||
"tagDesc" = "唯一標籤"
|
"tagDesc" = "唯一標籤"
|
||||||
"address" = "地址"
|
"address" = "地址"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue