Merge branch 'MHSanaei:main' into Feat-WireGuard-inbound-DNS

This commit is contained in:
SilverPolarFox 2026-05-06 15:06:48 +03:00 committed by GitHub
commit 4b92228c28
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 1094 additions and 461 deletions

View file

@ -1 +1 @@
2.9.3 2.9.4

View file

@ -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
} }

View file

@ -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",

File diff suppressed because one or more lines are too long

View file

@ -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;
} }
}; };

View file

@ -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) {

View file

@ -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 {

View file

@ -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">&infin;</span> <span v-else class="tr-infinity-ch">&infin;</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">&infin;</span> <span v-else class="tr-infinity-ch">&infin;</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' }">

View file

@ -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>

View file

@ -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">

View file

@ -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>

View file

@ -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}}

View file

@ -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) {

View file

@ -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 ]]&nbsp;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"

View file

@ -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}}

View file

@ -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" .}}

View file

@ -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(`

View 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
}

View 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)
}
}

View file

@ -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)
} }
} }

View file

@ -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" {

View file

@ -584,6 +584,9 @@
"addReverse" = "أضف عكسي" "addReverse" = "أضف عكسي"
"editOutbound" = "عدل المخرج" "editOutbound" = "عدل المخرج"
"editReverse" = "عدل العكسي" "editReverse" = "عدل العكسي"
"reverseTag" = "وسم العكسي"
"reverseTagDesc" = "وسم الخروج لبروكسي VLESS العكسي البسيط. اتركه فارغاً لتعطيله."
"reverseTagPlaceholder" = "وسم الخروج (اتركه فارغاً للتعطيل)"
"tag" = "تاج" "tag" = "تاج"
"tagDesc" = "تاج فريد" "tagDesc" = "تاج فريد"
"address" = "العنوان" "address" = "العنوان"

View file

@ -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"

View file

@ -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"

View file

@ -584,6 +584,9 @@
"addReverse" = "افزودن معکوس" "addReverse" = "افزودن معکوس"
"editOutbound" = "ویرایش خروجی" "editOutbound" = "ویرایش خروجی"
"editReverse" = "ویرایش معکوس" "editReverse" = "ویرایش معکوس"
"reverseTag" = "تگ معکوس"
"reverseTagDesc" = "تگ خروجی پروکسی معکوس ساده VLESS. برای غیرفعال کردن خالی بگذارید. در صورت تنظیم، اتصالات این کلاینت می‌توانند به عنوان تونل پروکسی معکوس استفاده شوند."
"reverseTagPlaceholder" = "تگ خروجی (خالی = غیرفعال)"
"tag" = "برچسب" "tag" = "برچسب"
"tagDesc" = "برچسب یگانه" "tagDesc" = "برچسب یگانه"
"address" = "آدرس" "address" = "آدرس"

View file

@ -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"

View file

@ -584,6 +584,9 @@
"addReverse" = "リバース追加" "addReverse" = "リバース追加"
"editOutbound" = "アウトバウンド編集" "editOutbound" = "アウトバウンド編集"
"editReverse" = "リバース編集" "editReverse" = "リバース編集"
"reverseTag" = "リバースタグ"
"reverseTagDesc" = "VLESSシンプルリバースプロキシのアウトバウンドタグ。無効にするには空欄にしてください。"
"reverseTagPlaceholder" = "アウトバウンドタグ(空欄で無効)"
"tag" = "タグ" "tag" = "タグ"
"tagDesc" = "一意のタグ" "tagDesc" = "一意のタグ"
"address" = "アドレス" "address" = "アドレス"

View file

@ -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"

View file

@ -584,6 +584,9 @@
"addReverse" = "Создать реверс-прокси" "addReverse" = "Создать реверс-прокси"
"editOutbound" = "Изменить исходящее подключение" "editOutbound" = "Изменить исходящее подключение"
"editReverse" = "Редактировать реверс-прокси" "editReverse" = "Редактировать реверс-прокси"
"reverseTag" = "Тег реверс-прокси"
"reverseTagDesc" = "Тег исходящего подключения для простого реверс-прокси VLESS. Оставьте пустым для отключения."
"reverseTagPlaceholder" = "тег исходящего (пусто = отключено)"
"tag" = "Тег" "tag" = "Тег"
"tagDesc" = "Уникальный тег" "tagDesc" = "Уникальный тег"
"address" = "Адрес" "address" = "Адрес"

View file

@ -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"

View file

@ -584,6 +584,9 @@
"addReverse" = "Додати реверс" "addReverse" = "Додати реверс"
"editOutbound" = "Редагувати вихідні" "editOutbound" = "Редагувати вихідні"
"editReverse" = "Редагувати реверс" "editReverse" = "Редагувати реверс"
"reverseTag" = "Тег реверс-проксі"
"reverseTagDesc" = "Тег вихідного з'єднання для простого реверс-проксі VLESS. Залиште порожнім для вимкнення."
"reverseTagPlaceholder" = "тег вихідного (порожнє = вимкнено)"
"tag" = "Тег" "tag" = "Тег"
"tagDesc" = "Унікальний тег" "tagDesc" = "Унікальний тег"
"address" = "Адреса" "address" = "Адреса"

View file

@ -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ỉ"

View file

@ -584,6 +584,9 @@
"addReverse" = "添加反向" "addReverse" = "添加反向"
"editOutbound" = "编辑出站" "editOutbound" = "编辑出站"
"editReverse" = "编辑反向" "editReverse" = "编辑反向"
"reverseTag" = "反向标签"
"reverseTagDesc" = "VLESS 简易反向代理出站标签。留空则禁用。设置后,此客户端的连接可用作反向代理隧道。"
"reverseTagPlaceholder" = "出站标签(留空则禁用)"
"tag" = "标签" "tag" = "标签"
"tagDesc" = "唯一标签" "tagDesc" = "唯一标签"
"address" = "地址" "address" = "地址"

View file

@ -584,6 +584,9 @@
"addReverse" = "新增反向" "addReverse" = "新增反向"
"editOutbound" = "編輯出站" "editOutbound" = "編輯出站"
"editReverse" = "編輯反向" "editReverse" = "編輯反向"
"reverseTag" = "反向標籤"
"reverseTagDesc" = "VLESS 簡易反向代理出站標籤。留空則停用。設定後,此客戶端的連線可作為反向代理隧道。"
"reverseTagPlaceholder" = "出站標籤(留空則停用)"
"tag" = "標籤" "tag" = "標籤"
"tagDesc" = "唯一標籤" "tagDesc" = "唯一標籤"
"address" = "地址" "address" = "地址"