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"`
|
||||
}
|
||||
|
||||
type ClientReverse struct {
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
|
||||
// Client represents a client configuration for Xray inbounds with traffic limits and settings.
|
||||
type Client struct {
|
||||
ID string `json:"id,omitempty"` // Unique client identifier
|
||||
Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm")
|
||||
Password string `json:"password,omitempty"` // Client password
|
||||
Flow string `json:"flow,omitempty"` // Flow control (XTLS)
|
||||
Auth string `json:"auth,omitempty"` // Auth password (Hysteria)
|
||||
Email string `json:"email"` // Client email identifier
|
||||
LimitIP int `json:"limitIp"` // IP limit for this client
|
||||
TotalGB int64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB
|
||||
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
|
||||
Enable bool `json:"enable" form:"enable"` // Whether the client is enabled
|
||||
TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications
|
||||
SubID string `json:"subId" form:"subId"` // Subscription identifier
|
||||
Comment string `json:"comment" form:"comment"` // Client comment
|
||||
Reset int `json:"reset" form:"reset"` // Reset period in days
|
||||
CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp
|
||||
UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp
|
||||
ID string `json:"id,omitempty"` // Unique client identifier
|
||||
Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm")
|
||||
Password string `json:"password,omitempty"` // Client password
|
||||
Flow string `json:"flow,omitempty"` // Flow control (XTLS)
|
||||
Reverse *ClientReverse `json:"reverse,omitempty"` // VLESS simple reverse proxy settings
|
||||
Auth string `json:"auth,omitempty"` // Auth password (Hysteria)
|
||||
Email string `json:"email"` // Client email identifier
|
||||
LimitIP int `json:"limitIp"` // IP limit for this client
|
||||
TotalGB int64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB
|
||||
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
|
||||
Enable bool `json:"enable" form:"enable"` // Whether the client is enabled
|
||||
TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications
|
||||
SubID string `json:"subId" form:"subId"` // Subscription identifier
|
||||
Comment string `json:"comment" form:"comment"` // Client comment
|
||||
Reset int `json:"reset" form:"reset"` // Reset period in days
|
||||
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
|
||||
if fragment != "" || noises != "" {
|
||||
fragmentOrNoises = true
|
||||
defaultOutboundsSettings := map[string]interface{}{
|
||||
defaultOutboundsSettings := map[string]any{
|
||||
"domainStrategy": "UseIP",
|
||||
"redirect": "",
|
||||
}
|
||||
|
|
@ -57,7 +57,7 @@ func NewSubJsonService(fragment string, noises string, mux string, rules string,
|
|||
defaultOutboundsSettings["noises"] = json_util.RawMessage(noises)
|
||||
}
|
||||
|
||||
defaultDirectOutbound := map[string]interface{}{
|
||||
defaultDirectOutbound := map[string]any{
|
||||
"protocol": "freedom",
|
||||
"settings": defaultOutboundsSettings,
|
||||
"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(
|
||||
id = RandomUtil.randomUUID(),
|
||||
flow = '',
|
||||
reverseTag = '',
|
||||
reverseSniffing = new Sniffing(),
|
||||
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.flow = flow;
|
||||
this.reverseTag = reverseTag;
|
||||
this.reverseSniffing = reverseSniffing;
|
||||
}
|
||||
|
||||
static fromJson(json = {}) {
|
||||
return new Inbound.VLESSSettings.VLESS(
|
||||
json.id,
|
||||
json.flow,
|
||||
json.reverse?.tag ?? '',
|
||||
Sniffing.fromJson(json.reverse?.sniffing || {}),
|
||||
...Inbound.ClientBase.commonArgsFromJson(json),
|
||||
);
|
||||
}
|
||||
|
||||
toJson() {
|
||||
return {
|
||||
const json = {
|
||||
id: this.id,
|
||||
flow: this.flow,
|
||||
...this._clientBaseToJson(),
|
||||
};
|
||||
if (this.reverseTag) {
|
||||
json.reverse = {
|
||||
tag: this.reverseTag,
|
||||
};
|
||||
}
|
||||
return json;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -50,6 +50,13 @@ const ALPN_OPTION = {
|
|||
HTTP1: "http/1.1",
|
||||
};
|
||||
|
||||
const SNIFFING_OPTION = {
|
||||
HTTP: "http",
|
||||
TLS: "tls",
|
||||
QUIC: "quic",
|
||||
FAKEDNS: "fakedns"
|
||||
};
|
||||
|
||||
const OutboundDomainStrategies = [
|
||||
"AsIs",
|
||||
"UseIP",
|
||||
|
|
@ -170,6 +177,7 @@ Object.freeze(SSMethods);
|
|||
Object.freeze(TLS_FLOW_CONTROL);
|
||||
Object.freeze(UTLS_FINGERPRINT);
|
||||
Object.freeze(ALPN_OPTION);
|
||||
Object.freeze(SNIFFING_OPTION);
|
||||
Object.freeze(OutboundDomainStrategies);
|
||||
Object.freeze(WireguardDomainStrategy);
|
||||
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 {
|
||||
constructor(type = 'none', host, path) {
|
||||
super();
|
||||
|
|
@ -1747,13 +1799,15 @@ Outbound.VmessSettings = 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();
|
||||
this.address = address;
|
||||
this.port = port;
|
||||
this.id = id;
|
||||
this.flow = flow;
|
||||
this.encryption = encryption;
|
||||
this.reverseTag = reverseTag;
|
||||
this.reverseSniffing = reverseSniffing;
|
||||
this.testpre = testpre;
|
||||
this.testseed = testseed;
|
||||
}
|
||||
|
|
@ -1766,6 +1820,8 @@ Outbound.VLESSSettings = class extends CommonClass {
|
|||
json.id,
|
||||
json.flow,
|
||||
json.encryption,
|
||||
json.reverse?.tag || '',
|
||||
ReverseSniffing.fromJson(json.reverse?.sniffing || {}),
|
||||
json.testpre || 0,
|
||||
json.testseed && json.testseed.length >= 4 ? json.testseed : [900, 500, 900, 256]
|
||||
);
|
||||
|
|
@ -1779,6 +1835,14 @@ Outbound.VLESSSettings = class extends CommonClass {
|
|||
flow: this.flow,
|
||||
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
|
||||
if (this.flow && this.flow !== '') {
|
||||
if (this.testpre > 0) {
|
||||
|
|
|
|||
|
|
@ -71,14 +71,19 @@ func (a *XraySettingController) getXraySetting(c *gin.Context) {
|
|||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
|
||||
return
|
||||
}
|
||||
clientReverseTags, err := a.InboundService.GetClientReverseTags()
|
||||
if err != nil {
|
||||
clientReverseTags = "[]"
|
||||
}
|
||||
outboundTestUrl, _ := a.SettingService.GetXrayOutboundTestUrl()
|
||||
if outboundTestUrl == "" {
|
||||
outboundTestUrl = "https://www.google.com/generate_204"
|
||||
}
|
||||
xrayResponse := map[string]interface{}{
|
||||
"xraySetting": json.RawMessage(xraySetting),
|
||||
"inboundTags": json.RawMessage(inboundTags),
|
||||
"outboundTestUrl": outboundTestUrl,
|
||||
xrayResponse := map[string]any{
|
||||
"xraySetting": json.RawMessage(xraySetting),
|
||||
"inboundTags": json.RawMessage(inboundTags),
|
||||
"clientReverseTags": json.RawMessage(clientReverseTags),
|
||||
"outboundTestUrl": outboundTestUrl,
|
||||
}
|
||||
result, err := json.Marshal(xrayResponse)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@
|
|||
</a-popover>
|
||||
</template>
|
||||
<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>
|
||||
<template slot="title">
|
||||
<template v-if="isClientDepleted(record, client.email)">{{ i18n "depleted" }}</template>
|
||||
|
|
@ -65,8 +65,10 @@
|
|||
<a-badge :class="isClientOnline(client.email)? 'online-animation' : ''"
|
||||
:color="client.enable ? statsExpColor(record, client.email) : themeSwitcher.isDarkTheme ? '#2c3950' : '#bcbcbc'"></a-badge>
|
||||
</a-tooltip>
|
||||
<a-space direction="vertical" :size="2">
|
||||
<span class="client-email">[[ client.email ]]</span>
|
||||
<a-space direction="vertical" :size="2" style="min-width:0;overflow:hidden">
|
||||
<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()">
|
||||
<a-tooltip v-if="client.comment.length > 50" :overlay-class-name="themeSwitcher.currentTheme">
|
||||
<template slot="title">
|
||||
|
|
@ -94,7 +96,7 @@
|
|||
</table>
|
||||
</template>
|
||||
<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">
|
||||
<a-progress :stroke-color="themeSwitcher.isDarkTheme ? 'rgb(72 84 105)' : '#bcbcbc'" :show-info="false" :percent="statsProgress(record, client.email)" />
|
||||
</div>
|
||||
|
|
@ -104,7 +106,7 @@
|
|||
<div v-else class="infinite-bar tr-table-bar">
|
||||
<a-progress :show-info="false" :percent="100"></a-progress>
|
||||
</div>
|
||||
<div class="tr-table-lt">
|
||||
<div class="tr-table-rt">
|
||||
<template v-if="client.totalGB > 0">[[ client._totalGB + "GB" ]]</template>
|
||||
<span v-else class="tr-infinity-ch">∞</span>
|
||||
</div>
|
||||
|
|
@ -184,20 +186,20 @@
|
|||
</a-dropdown>
|
||||
</template>
|
||||
<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">
|
||||
<table>
|
||||
<tr>
|
||||
<td colspan="3" :style="{ textAlign: 'center' }">{{ i18n "pages.inbounds.traffic" }}</td>
|
||||
</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>
|
||||
<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"
|
||||
:percent="statsProgress(record, client.email)" />
|
||||
</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">
|
||||
<template slot="content" v-if="client.email">
|
||||
<table cellpadding="2" width="100%">
|
||||
|
|
@ -216,11 +218,11 @@
|
|||
:percent="statsProgress(record, client.email)" />
|
||||
</a-popover>
|
||||
</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"
|
||||
:percent="100"></a-progress>
|
||||
</td>
|
||||
<td width="80px">
|
||||
<td width="60px">
|
||||
<template v-if="client.totalGB > 0">[[ client._totalGB + "GB" ]]</template>
|
||||
<span v-else class="tr-infinity-ch">∞</span>
|
||||
</td>
|
||||
|
|
@ -233,9 +235,9 @@
|
|||
</tr>
|
||||
<tr>
|
||||
<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>
|
||||
<td width="120px" class="infinite-bar">
|
||||
<td width="90px" class="infinite-bar">
|
||||
<a-popover :overlay-class-name="themeSwitcher.currentTheme">
|
||||
<template slot="content">
|
||||
<span v-if="client.expiryTime < 0">{{ i18n "pages.client.delayedStart" }}</span>
|
||||
|
|
@ -245,7 +247,7 @@
|
|||
:percent="expireProgress(client.expiryTime, client.reset)" />
|
||||
</a-popover>
|
||||
</td>
|
||||
<td width="60px">[[ client.reset + "d" ]]</td>
|
||||
<td width="50px">[[ client.reset + "d" ]]</td>
|
||||
</template>
|
||||
<template v-else>
|
||||
<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>
|
||||
</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>
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
|
|
|
|||
|
|
@ -342,6 +342,53 @@
|
|||
<a-form-item label="encryption">
|
||||
<a-input v-model.trim="outbound.settings.encryption"></a-input>
|
||||
</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 v-if="outbound.canEnableTlsFlow()">
|
||||
<a-form-item label="Flow">
|
||||
|
|
|
|||
|
|
@ -648,7 +648,7 @@
|
|||
:data-source="getInboundClients(record)"
|
||||
:pagination=pagination(getInboundClients(record))
|
||||
:scroll="isMobile ? {} : { x: 'max-content' }"
|
||||
:style="{ margin: `-10px ${isMobile ? '2px' : '22px'} -11px` }">
|
||||
:style="{ margin: `-10px ${isMobile ? '2px' : '22px'} -21px` }">
|
||||
{{template "component/aClientTable" .}}
|
||||
</a-table>
|
||||
</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)
|
||||
this.outboundTags = ["", ...app.templateSettings.outbounds.filter((o) => !ObjectUtil.isEmpty(o.tag)).map(obj =>
|
||||
obj.tag)];
|
||||
if (app.templateSettings.reverse) {
|
||||
if (app.templateSettings.reverse.bridges) {
|
||||
this.inboundTags.push(...app.templateSettings.reverse.bridges.map(b => b.tag));
|
||||
}
|
||||
if (app.templateSettings.reverse.portals) this.outboundTags.push(...app.templateSettings.reverse.portals.map(
|
||||
b => b.tag));
|
||||
if (app.clientReverseTags) {
|
||||
app.clientReverseTags.forEach(tag => {
|
||||
if (tag && !this.outboundTags.includes(tag)) {
|
||||
this.outboundTags.push(tag);
|
||||
}
|
||||
});
|
||||
}
|
||||
this.balancerTags = [""];
|
||||
if (app.templateSettings.routing && app.templateSettings.routing.balancers) {
|
||||
|
|
|
|||
|
|
@ -4,8 +4,7 @@
|
|||
<a-col :xs="24" :sm="14" :lg="14">
|
||||
<a-space direction="horizontal" size="small" class="outbounds-toolbar">
|
||||
<a-button type="primary" icon="plus" @click="addOutbound">
|
||||
<span v-if="!isMobile">{{ i18n
|
||||
"pages.xray.outbound.addOutbound" }}</span>
|
||||
<span v-if="!isMobile">{{ i18n "pages.xray.outbound.addOutbound" }}</span>
|
||||
</a-button>
|
||||
<a-button type="primary" icon="cloud" @click="showWarp()">WARP</a-button>
|
||||
<a-button type="primary" icon="api" @click="showNord()">NordVPN</a-button>
|
||||
|
|
@ -25,9 +24,114 @@
|
|||
</a-button-group>
|
||||
</a-col>
|
||||
</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"
|
||||
:scroll="isMobile ? { x: 720 } : {}"
|
||||
:scroll="{}"
|
||||
:pagination="false"
|
||||
:indent-size="0"
|
||||
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 "settings/xray/outbounds" . }}
|
||||
</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">
|
||||
<template #tab>
|
||||
<a-icon type="cluster"></a-icon>
|
||||
|
|
@ -135,7 +128,6 @@
|
|||
{{template "component/aSettingListItem" .}}
|
||||
{{template "modals/ruleModal" .}}
|
||||
{{template "modals/outModal" .}}
|
||||
{{template "modals/reverseModal" .}}
|
||||
{{template "modals/balancerModal" .}}
|
||||
{{template "modals/dnsModal" .}}
|
||||
{{template "modals/dnsPresetsModal" .}}
|
||||
|
|
@ -177,41 +169,13 @@
|
|||
// network + security pills sit underneath it. Width chosen so the three
|
||||
// longest tonal pills (e.g. vless + httpupgrade + reality) fit on a
|
||||
// single line without wrapping.
|
||||
{ title: '{{ i18n "pages.xray.outbound.tag"}}', align: 'left', width: 280, scopedSlots: { customRender: 'identity' } },
|
||||
{ title: '{{ i18n "pages.xray.outbound.address"}}', align: 'left', scopedSlots: { customRender: 'address' } },
|
||||
{ title: '{{ i18n "pages.xray.outbound.tag"}}', align: 'left', width: 190, scopedSlots: { customRender: 'identity' } },
|
||||
{ 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.xray.outbound.testResult" }}', align: 'left', width: 130, scopedSlots: { customRender: 'testResult' } },
|
||||
{ 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 = [{
|
||||
title: "#",
|
||||
align: 'center',
|
||||
|
|
@ -316,6 +280,7 @@
|
|||
outboundTestUrl: 'https://www.google.com/generate_204',
|
||||
oldOutboundTestUrl: 'https://www.google.com/generate_204',
|
||||
inboundTags: [],
|
||||
clientReverseTags: [],
|
||||
outboundsTraffic: [],
|
||||
outboundTestStates: {}, // Track testing state and results for each outbound
|
||||
saveBtnDisable: true,
|
||||
|
|
@ -595,6 +560,7 @@
|
|||
this.oldXraySetting = xs;
|
||||
this.xraySetting = xs;
|
||||
this.inboundTags = result.inboundTags;
|
||||
this.clientReverseTags = result.clientReverseTags || [];
|
||||
this.outboundTestUrl = result.outboundTestUrl || 'https://www.google.com/generate_204';
|
||||
this.oldOutboundTestUrl = this.outboundTestUrl;
|
||||
this.saveBtnDisable = true;
|
||||
|
|
@ -945,110 +911,6 @@
|
|||
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() {
|
||||
if (!this.refreshing) {
|
||||
this.refreshing = true;
|
||||
|
|
@ -1457,32 +1319,6 @@
|
|||
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: {
|
||||
get: function() {
|
||||
return this.templateSettings ? JSON.stringify(this.templateSettings.routing.rules, null, 2) : null;
|
||||
|
|
@ -2156,7 +1992,7 @@
|
|||
line-height: 1.5;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
display: inline-block;
|
||||
max-width: 240px;
|
||||
max-width: 190px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
|
@ -2437,5 +2273,89 @@
|
|||
}
|
||||
.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>
|
||||
{{ template "page/body_end" .}}
|
||||
|
|
@ -104,36 +104,6 @@ func (s *InboundService) GetInboundsByTrafficReset(period string) ([]*model.Inbo
|
|||
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) {
|
||||
settings := map[string][]model.Client{}
|
||||
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.
|
||||
// Returns the created inbound, whether Xray needs restart, and any 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 {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
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.
|
||||
// Returns the updated inbound, whether Xray needs restart, and any 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 {
|
||||
return inbound, false, err
|
||||
}
|
||||
|
|
@ -565,10 +545,11 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
|||
oldInbound.Settings = inbound.Settings
|
||||
oldInbound.StreamSettings = inbound.StreamSettings
|
||||
oldInbound.Sniffing = inbound.Sniffing
|
||||
if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" {
|
||||
oldInbound.Tag = fmt.Sprintf("inbound-%v", inbound.Port)
|
||||
} else {
|
||||
oldInbound.Tag = fmt.Sprintf("inbound-%v:%v", inbound.Listen, inbound.Port)
|
||||
// regenerate tag with collision-aware logic. for this row we pass
|
||||
// inbound.Id as ignoreId so it doesn't see its own old tag in the db.
|
||||
oldInbound.Tag, err = s.generateInboundTag(inbound, inbound.Id)
|
||||
if err != nil {
|
||||
return inbound, false, err
|
||||
}
|
||||
|
||||
needRestart := false
|
||||
|
|
@ -1764,6 +1745,51 @@ func (s *InboundService) GetInboundTags() (string, error) {
|
|||
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() {
|
||||
db := database.GetDB()
|
||||
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
|
||||
for _, release := range releases {
|
||||
tagVersion := strings.TrimPrefix(release.TagName, "v")
|
||||
if tagVersion == "26.5.3" {
|
||||
continue
|
||||
}
|
||||
tagParts := strings.Split(tagVersion, ".")
|
||||
if len(tagParts) != 3 {
|
||||
continue
|
||||
|
|
@ -567,7 +570,7 @@ func (s *ServerService) GetXrayVersions() ([]string, error) {
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -148,7 +148,7 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
|
|||
|
||||
// clear client config for additional parameters
|
||||
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)
|
||||
}
|
||||
if flow, ok := c["flow"].(string); ok && flow == "xtls-rprx-vision-udp443" {
|
||||
|
|
|
|||
|
|
@ -584,6 +584,9 @@
|
|||
"addReverse" = "أضف عكسي"
|
||||
"editOutbound" = "عدل المخرج"
|
||||
"editReverse" = "عدل العكسي"
|
||||
"reverseTag" = "وسم العكسي"
|
||||
"reverseTagDesc" = "وسم الخروج لبروكسي VLESS العكسي البسيط. اتركه فارغاً لتعطيله."
|
||||
"reverseTagPlaceholder" = "وسم الخروج (اتركه فارغاً للتعطيل)"
|
||||
"tag" = "تاج"
|
||||
"tagDesc" = "تاج فريد"
|
||||
"address" = "العنوان"
|
||||
|
|
|
|||
|
|
@ -584,6 +584,9 @@
|
|||
"addReverse" = "Add Reverse"
|
||||
"editOutbound" = "Edit Outbound"
|
||||
"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"
|
||||
"tagDesc" = "Unique Tag"
|
||||
"address" = "Address"
|
||||
|
|
|
|||
|
|
@ -584,6 +584,9 @@
|
|||
"addReverse" = "Agregar reverso"
|
||||
"editOutbound" = "Editar salida"
|
||||
"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"
|
||||
"tagDesc" = "etiqueta única"
|
||||
"address" = "Dirección"
|
||||
|
|
|
|||
|
|
@ -584,6 +584,9 @@
|
|||
"addReverse" = "افزودن معکوس"
|
||||
"editOutbound" = "ویرایش خروجی"
|
||||
"editReverse" = "ویرایش معکوس"
|
||||
"reverseTag" = "تگ معکوس"
|
||||
"reverseTagDesc" = "تگ خروجی پروکسی معکوس ساده VLESS. برای غیرفعال کردن خالی بگذارید. در صورت تنظیم، اتصالات این کلاینت میتوانند به عنوان تونل پروکسی معکوس استفاده شوند."
|
||||
"reverseTagPlaceholder" = "تگ خروجی (خالی = غیرفعال)"
|
||||
"tag" = "برچسب"
|
||||
"tagDesc" = "برچسب یگانه"
|
||||
"address" = "آدرس"
|
||||
|
|
|
|||
|
|
@ -584,6 +584,9 @@
|
|||
"addReverse" = "Tambahkan Revers"
|
||||
"editOutbound" = "Edit Keluar"
|
||||
"editReverse" = "Edit Revers"
|
||||
"reverseTag" = "Tag Revers"
|
||||
"reverseTagDesc" = "Tag outbound proxy revers sederhana VLESS. Kosongkan untuk menonaktifkan."
|
||||
"reverseTagPlaceholder" = "tag outbound (kosong untuk menonaktifkan)"
|
||||
"tag" = "Tag"
|
||||
"tagDesc" = "Tag Unik"
|
||||
"address" = "Alamat"
|
||||
|
|
|
|||
|
|
@ -584,6 +584,9 @@
|
|||
"addReverse" = "リバース追加"
|
||||
"editOutbound" = "アウトバウンド編集"
|
||||
"editReverse" = "リバース編集"
|
||||
"reverseTag" = "リバースタグ"
|
||||
"reverseTagDesc" = "VLESSシンプルリバースプロキシのアウトバウンドタグ。無効にするには空欄にしてください。"
|
||||
"reverseTagPlaceholder" = "アウトバウンドタグ(空欄で無効)"
|
||||
"tag" = "タグ"
|
||||
"tagDesc" = "一意のタグ"
|
||||
"address" = "アドレス"
|
||||
|
|
|
|||
|
|
@ -584,6 +584,9 @@
|
|||
"addReverse" = "Adicionar Reverso"
|
||||
"editOutbound" = "Editar Saída"
|
||||
"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"
|
||||
"tagDesc" = "Tag Única"
|
||||
"address" = "Endereço"
|
||||
|
|
|
|||
|
|
@ -584,6 +584,9 @@
|
|||
"addReverse" = "Создать реверс-прокси"
|
||||
"editOutbound" = "Изменить исходящее подключение"
|
||||
"editReverse" = "Редактировать реверс-прокси"
|
||||
"reverseTag" = "Тег реверс-прокси"
|
||||
"reverseTagDesc" = "Тег исходящего подключения для простого реверс-прокси VLESS. Оставьте пустым для отключения."
|
||||
"reverseTagPlaceholder" = "тег исходящего (пусто = отключено)"
|
||||
"tag" = "Тег"
|
||||
"tagDesc" = "Уникальный тег"
|
||||
"address" = "Адрес"
|
||||
|
|
|
|||
|
|
@ -584,6 +584,9 @@
|
|||
"addReverse" = "Ters Ekle"
|
||||
"editOutbound" = "Gideni 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"
|
||||
"tagDesc" = "Benzersiz Etiket"
|
||||
"address" = "Adres"
|
||||
|
|
|
|||
|
|
@ -584,6 +584,9 @@
|
|||
"addReverse" = "Додати реверс"
|
||||
"editOutbound" = "Редагувати вихідні"
|
||||
"editReverse" = "Редагувати реверс"
|
||||
"reverseTag" = "Тег реверс-проксі"
|
||||
"reverseTagDesc" = "Тег вихідного з'єднання для простого реверс-проксі VLESS. Залиште порожнім для вимкнення."
|
||||
"reverseTagPlaceholder" = "тег вихідного (порожнє = вимкнено)"
|
||||
"tag" = "Тег"
|
||||
"tagDesc" = "Унікальний тег"
|
||||
"address" = "Адреса"
|
||||
|
|
|
|||
|
|
@ -584,6 +584,9 @@
|
|||
"addReverse" = "Thêm đảo ngược"
|
||||
"editOutbound" = "Chỉnh sửa gửi đ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ẻ"
|
||||
"tagDesc" = "thẻ duy nhất"
|
||||
"address" = "Địa chỉ"
|
||||
|
|
|
|||
|
|
@ -584,6 +584,9 @@
|
|||
"addReverse" = "添加反向"
|
||||
"editOutbound" = "编辑出站"
|
||||
"editReverse" = "编辑反向"
|
||||
"reverseTag" = "反向标签"
|
||||
"reverseTagDesc" = "VLESS 简易反向代理出站标签。留空则禁用。设置后,此客户端的连接可用作反向代理隧道。"
|
||||
"reverseTagPlaceholder" = "出站标签(留空则禁用)"
|
||||
"tag" = "标签"
|
||||
"tagDesc" = "唯一标签"
|
||||
"address" = "地址"
|
||||
|
|
|
|||
|
|
@ -584,6 +584,9 @@
|
|||
"addReverse" = "新增反向"
|
||||
"editOutbound" = "編輯出站"
|
||||
"editReverse" = "編輯反向"
|
||||
"reverseTag" = "反向標籤"
|
||||
"reverseTagDesc" = "VLESS 簡易反向代理出站標籤。留空則停用。設定後,此客戶端的連線可作為反向代理隧道。"
|
||||
"reverseTagPlaceholder" = "出站標籤(留空則停用)"
|
||||
"tag" = "標籤"
|
||||
"tagDesc" = "唯一標籤"
|
||||
"address" = "地址"
|
||||
|
|
|
|||
Loading…
Reference in a new issue