mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-02-13 13:57:59 +00:00
Add url speed test for outbound (#3767)
* add outbound testing functionality with configurable test URL * use no kernel tun for conflict errors
This commit is contained in:
parent
4a455aa532
commit
4779939424
22 changed files with 746 additions and 102 deletions
|
|
@ -1,6 +1,9 @@
|
|||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v2/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
|
@ -34,9 +37,10 @@ func (a *XraySettingController) initRouter(g *gin.RouterGroup) {
|
|||
g.POST("/warp/:action", a.warp)
|
||||
g.POST("/update", a.updateSetting)
|
||||
g.POST("/resetOutboundsTraffic", a.resetOutboundsTraffic)
|
||||
g.POST("/testOutbound", a.testOutbound)
|
||||
}
|
||||
|
||||
// getXraySetting retrieves the Xray configuration template and inbound tags.
|
||||
// getXraySetting retrieves the Xray configuration template, inbound tags, and outbound test URL.
|
||||
func (a *XraySettingController) getXraySetting(c *gin.Context) {
|
||||
xraySetting, err := a.SettingService.GetXrayConfigTemplate()
|
||||
if err != nil {
|
||||
|
|
@ -48,15 +52,28 @@ func (a *XraySettingController) getXraySetting(c *gin.Context) {
|
|||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
|
||||
return
|
||||
}
|
||||
xrayResponse := "{ \"xraySetting\": " + xraySetting + ", \"inboundTags\": " + inboundTags + " }"
|
||||
outboundTestUrl, _ := a.SettingService.GetXrayOutboundTestUrl()
|
||||
if outboundTestUrl == "" {
|
||||
outboundTestUrl = "https://www.google.com/generate_204"
|
||||
}
|
||||
urlJSON, _ := json.Marshal(outboundTestUrl)
|
||||
xrayResponse := "{ \"xraySetting\": " + xraySetting + ", \"inboundTags\": " + inboundTags + ", \"outboundTestUrl\": " + string(urlJSON) + " }"
|
||||
jsonObj(c, xrayResponse, nil)
|
||||
}
|
||||
|
||||
// updateSetting updates the Xray configuration settings.
|
||||
func (a *XraySettingController) updateSetting(c *gin.Context) {
|
||||
xraySetting := c.PostForm("xraySetting")
|
||||
err := a.XraySettingService.SaveXraySetting(xraySetting)
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
|
||||
if err := a.XraySettingService.SaveXraySetting(xraySetting); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
|
||||
return
|
||||
}
|
||||
outboundTestUrl := c.PostForm("outboundTestUrl")
|
||||
if outboundTestUrl == "" {
|
||||
outboundTestUrl = "https://www.google.com/generate_204"
|
||||
}
|
||||
_ = a.SettingService.SetXrayOutboundTestUrl(outboundTestUrl)
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), nil)
|
||||
}
|
||||
|
||||
// getDefaultXrayConfig retrieves the default Xray configuration.
|
||||
|
|
@ -118,3 +135,24 @@ func (a *XraySettingController) resetOutboundsTraffic(c *gin.Context) {
|
|||
}
|
||||
jsonObj(c, "", nil)
|
||||
}
|
||||
|
||||
// testOutbound tests an outbound configuration and returns the delay/response time.
|
||||
// Optional form "allOutbounds": JSON array of all outbounds; used to resolve sockopt.dialerProxy dependencies.
|
||||
func (a *XraySettingController) testOutbound(c *gin.Context) {
|
||||
outboundJSON := c.PostForm("outbound")
|
||||
testURL := c.PostForm("testURL")
|
||||
allOutboundsJSON := c.PostForm("allOutbounds")
|
||||
|
||||
if outboundJSON == "" {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), common.NewError("outbound parameter is required"))
|
||||
return
|
||||
}
|
||||
|
||||
result, err := a.OutboundService.TestOutbound(outboundJSON, testURL, allOutboundsJSON)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonObj(c, result, nil)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,18 +4,22 @@
|
|||
<a-row :xs="24" :sm="24" :lg="12">
|
||||
<a-alert type="warning" :style="{ textAlign: 'center' }">
|
||||
<template slot="message">
|
||||
<a-icon type="exclamation-circle" theme="filled" :style="{ color: '#FFA031' }"></a-icon>
|
||||
<a-icon type="exclamation-circle" theme="filled"
|
||||
:style="{ color: '#FFA031' }"></a-icon>
|
||||
<span>{{ i18n "pages.xray.generalConfigsDesc" }}</span>
|
||||
</template>
|
||||
</a-alert>
|
||||
</a-row>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.xray.FreedomStrategy" }}</template>
|
||||
<template #description>{{ i18n "pages.xray.FreedomStrategyDesc" }}</template>
|
||||
<template #description>{{ i18n "pages.xray.FreedomStrategyDesc"
|
||||
}}</template>
|
||||
<template #control>
|
||||
<a-select v-model="freedomStrategy" :dropdown-class-name="themeSwitcher.currentTheme"
|
||||
<a-select v-model="freedomStrategy"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
:style="{ width: '100%' }">
|
||||
<a-select-option v-for="s in OutboundDomainStrategies" :value="s">
|
||||
<a-select-option v-for="s in OutboundDomainStrategies"
|
||||
:value="s">
|
||||
<span>[[ s ]]</span>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
|
|
@ -23,42 +27,63 @@
|
|||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.xray.RoutingStrategy" }}</template>
|
||||
<template #description>{{ i18n "pages.xray.RoutingStrategyDesc" }}</template>
|
||||
<template #description>{{ i18n "pages.xray.RoutingStrategyDesc"
|
||||
}}</template>
|
||||
<template #control>
|
||||
<a-select v-model="routingStrategy" :dropdown-class-name="themeSwitcher.currentTheme"
|
||||
<a-select v-model="routingStrategy"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
:style="{ width: '100%' }">
|
||||
<a-select-option v-for="s in routingDomainStrategies" :value="s">
|
||||
<a-select-option v-for="s in routingDomainStrategies"
|
||||
:value="s">
|
||||
<span>[[ s ]]</span>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.xray.outboundTestUrl" }}</template>
|
||||
<template #description>{{ i18n "pages.xray.outboundTestUrlDesc"
|
||||
}}</template>
|
||||
<template #control>
|
||||
<a-input v-model="outboundTestUrl"
|
||||
:placeholder="'https://www.google.com/generate_204'"
|
||||
:style="{ width: '100%' }"></a-input>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
</a-collapse-panel>
|
||||
<a-collapse-panel key="2" header='{{ i18n "pages.xray.statistics" }}'>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.xray.statsInboundUplink" }}</template>
|
||||
<template #description>{{ i18n "pages.xray.statsInboundUplinkDesc" }}</template>
|
||||
<template #title>{{ i18n "pages.xray.statsInboundUplink"
|
||||
}}</template>
|
||||
<template #description>{{ i18n "pages.xray.statsInboundUplinkDesc"
|
||||
}}</template>
|
||||
<template #control>
|
||||
<a-switch v-model="statsInboundUplink"></a-switch>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.xray.statsInboundDownlink" }}</template>
|
||||
<template #description>{{ i18n "pages.xray.statsInboundDownlinkDesc" }}</template>
|
||||
<template #title>{{ i18n "pages.xray.statsInboundDownlink"
|
||||
}}</template>
|
||||
<template #description>{{ i18n "pages.xray.statsInboundDownlinkDesc"
|
||||
}}</template>
|
||||
<template #control>
|
||||
<a-switch v-model="statsInboundDownlink"></a-switch>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.xray.statsOutboundUplink" }}</template>
|
||||
<template #description>{{ i18n "pages.xray.statsOutboundUplinkDesc" }}</template>
|
||||
<template #title>{{ i18n "pages.xray.statsOutboundUplink"
|
||||
}}</template>
|
||||
<template #description>{{ i18n "pages.xray.statsOutboundUplinkDesc"
|
||||
}}</template>
|
||||
<template #control>
|
||||
<a-switch v-model="statsOutboundUplink"></a-switch>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.xray.statsOutboundDownlink" }}</template>
|
||||
<template #description>{{ i18n "pages.xray.statsOutboundDownlinkDesc" }}</template>
|
||||
<template #title>{{ i18n "pages.xray.statsOutboundDownlink"
|
||||
}}</template>
|
||||
<template #description>{{ i18n
|
||||
"pages.xray.statsOutboundDownlinkDesc" }}</template>
|
||||
<template #control>
|
||||
<a-switch v-model="statsOutboundDownlink"></a-switch>
|
||||
</template>
|
||||
|
|
@ -68,16 +93,20 @@
|
|||
<a-row :xs="24" :sm="24" :lg="12">
|
||||
<a-alert type="warning" :style="{ textAlign: 'center' }">
|
||||
<template slot="message">
|
||||
<a-icon type="exclamation-circle" theme="filled" :style="{ color: '#FFA031' }"></a-icon>
|
||||
<a-icon type="exclamation-circle" theme="filled"
|
||||
:style="{ color: '#FFA031' }"></a-icon>
|
||||
<span>{{ i18n "pages.xray.logConfigsDesc" }}</span>
|
||||
</template>
|
||||
</a-alert>
|
||||
</a-row>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.xray.logLevel" }}</template>
|
||||
<template #description>{{ i18n "pages.xray.logLevelDesc" }}</template>
|
||||
<template #description>{{ i18n "pages.xray.logLevelDesc"
|
||||
}}</template>
|
||||
<template #control>
|
||||
<a-select v-model="logLevel" :dropdown-class-name="themeSwitcher.currentTheme" :style="{ width: '100%' }">
|
||||
<a-select v-model="logLevel"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
:style="{ width: '100%' }">
|
||||
<a-select-option v-for="s in log.loglevel" :value="s">
|
||||
<span>[[ s ]]</span>
|
||||
</a-select-option>
|
||||
|
|
@ -86,10 +115,13 @@
|
|||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.xray.accessLog" }}</template>
|
||||
<template #description>{{ i18n "pages.xray.accessLogDesc" }}</template>
|
||||
<template #description>{{ i18n "pages.xray.accessLogDesc"
|
||||
}}</template>
|
||||
<template #control>
|
||||
<a-select v-model="accessLog" :dropdown-class-name="themeSwitcher.currentTheme" :style="{ width: '100%' }">
|
||||
<a-select-option value=''>
|
||||
<a-select v-model="accessLog"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
:style="{ width: '100%' }">
|
||||
<a-select-option value>
|
||||
<span>Empty</span>
|
||||
</a-select-option>
|
||||
<a-select-option v-for="s in log.access" :value="s">
|
||||
|
|
@ -100,10 +132,13 @@
|
|||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.xray.errorLog" }}</template>
|
||||
<template #description>{{ i18n "pages.xray.errorLogDesc" }}</template>
|
||||
<template #description>{{ i18n "pages.xray.errorLogDesc"
|
||||
}}</template>
|
||||
<template #control>
|
||||
<a-select v-model="errorLog" :dropdown-class-name="themeSwitcher.currentTheme" :style="{ width: '100%' }">
|
||||
<a-select-option value=''>
|
||||
<a-select v-model="errorLog"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
:style="{ width: '100%' }">
|
||||
<a-select-option value>
|
||||
<span>Empty</span>
|
||||
</a-select-option>
|
||||
<a-select-option v-for="s in log.error" :value="s">
|
||||
|
|
@ -114,11 +149,13 @@
|
|||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.xray.maskAddress" }}</template>
|
||||
<template #description>{{ i18n "pages.xray.maskAddressDesc" }}</template>
|
||||
<template #description>{{ i18n "pages.xray.maskAddressDesc"
|
||||
}}</template>
|
||||
<template #control>
|
||||
<a-select v-model="maskAddressLog" :dropdown-class-name="themeSwitcher.currentTheme"
|
||||
<a-select v-model="maskAddressLog"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
:style="{ width: '100%' }">
|
||||
<a-select-option value=''>
|
||||
<a-select-option value>
|
||||
<span>Empty</span>
|
||||
</a-select-option>
|
||||
<a-select-option v-for="s in log.maskAddress" :value="s">
|
||||
|
|
@ -139,7 +176,8 @@
|
|||
<a-row :xs="24" :sm="24" :lg="12">
|
||||
<a-alert type="warning" :style="{ textAlign: 'center' }">
|
||||
<template slot="message">
|
||||
<a-icon type="exclamation-circle" theme="filled" :style="{ color: '#FFA031' }"></a-icon>
|
||||
<a-icon type="exclamation-circle" theme="filled"
|
||||
:style="{ color: '#FFA031' }"></a-icon>
|
||||
<span>{{ i18n "pages.xray.blockConfigsDesc" }}</span>
|
||||
</template>
|
||||
</a-alert>
|
||||
|
|
@ -153,17 +191,21 @@
|
|||
<a-row :xs="24" :sm="24" :lg="12">
|
||||
<a-alert type="warning" :style="{ textAlign: 'center' }">
|
||||
<template slot="message">
|
||||
<a-icon type="exclamation-circle" theme="filled" :style="{ color: '#FFA031' }"></a-icon>
|
||||
<span>{{ i18n "pages.xray.blockConnectionsConfigsDesc" }}</span>
|
||||
<a-icon type="exclamation-circle" theme="filled"
|
||||
:style="{ color: '#FFA031' }"></a-icon>
|
||||
<span>{{ i18n "pages.xray.blockConnectionsConfigsDesc"
|
||||
}}</span>
|
||||
</template>
|
||||
</a-alert>
|
||||
</a-row>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.xray.blockips" }}</template>
|
||||
<template #control>
|
||||
<a-select mode="tags" v-model="blockedIPs" :style="{ width: '100%' }"
|
||||
<a-select mode="tags" v-model="blockedIPs"
|
||||
:style="{ width: '100%' }"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option :value="p.value" :label="p.label" v-for="p in settingsData.IPsOptions">
|
||||
<a-select-option :value="p.value" :label="p.label"
|
||||
v-for="p in settingsData.IPsOptions">
|
||||
<span>[[ p.label ]]</span>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
|
|
@ -172,28 +214,35 @@
|
|||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.xray.blockdomains" }}</template>
|
||||
<template #control>
|
||||
<a-select mode="tags" v-model="blockedDomains" :style="{ width: '100%' }"
|
||||
<a-select mode="tags" v-model="blockedDomains"
|
||||
:style="{ width: '100%' }"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option :value="p.value" :label="p.label" v-for="p in settingsData.BlockDomainsOptions">
|
||||
<a-select-option :value="p.value" :label="p.label"
|
||||
v-for="p in settingsData.BlockDomainsOptions">
|
||||
<span>[[ p.label ]]</span>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-row :xs="24" :sm="24" :lg="12">
|
||||
<a-alert type="warning" :style="{ textAlign: 'center', marginTop: '20px' }">
|
||||
<a-alert type="warning"
|
||||
:style="{ textAlign: 'center', marginTop: '20px' }">
|
||||
<template slot="message">
|
||||
<a-icon type="exclamation-circle" theme="filled" :style="{ color: '#FFA031' }"></a-icon>
|
||||
<span>{{ i18n "pages.xray.directConnectionsConfigsDesc" }}</span>
|
||||
<a-icon type="exclamation-circle" theme="filled"
|
||||
:style="{ color: '#FFA031' }"></a-icon>
|
||||
<span>{{ i18n "pages.xray.directConnectionsConfigsDesc"
|
||||
}}</span>
|
||||
</template>
|
||||
</a-alert>
|
||||
</a-row>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.xray.directips" }}</template>
|
||||
<template #control>
|
||||
<a-select mode="tags" :style="{ width: '100%' }" v-model="directIPs"
|
||||
<a-select mode="tags" :style="{ width: '100%' }"
|
||||
v-model="directIPs"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option :value="p.value" :label="p.label" v-for="p in settingsData.IPsOptions">
|
||||
<a-select-option :value="p.value" :label="p.label"
|
||||
v-for="p in settingsData.IPsOptions">
|
||||
<span>[[ p.label ]]</span>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
|
|
@ -202,18 +251,22 @@
|
|||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.xray.directdomains" }}</template>
|
||||
<template #control>
|
||||
<a-select mode="tags" :style="{ width: '100%' }" v-model="directDomains"
|
||||
<a-select mode="tags" :style="{ width: '100%' }"
|
||||
v-model="directDomains"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option :value="p.value" :label="p.label" v-for="p in settingsData.DomainsOptions">
|
||||
<a-select-option :value="p.value" :label="p.label"
|
||||
v-for="p in settingsData.DomainsOptions">
|
||||
<span>[[ p.label ]]</span>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-row :xs="24" :sm="24" :lg="12">
|
||||
<a-alert type="warning" :style="{ textAlign: 'center', marginTop: '20px' }">
|
||||
<a-alert type="warning"
|
||||
:style="{ textAlign: 'center', marginTop: '20px' }">
|
||||
<template slot="message">
|
||||
<a-icon type="exclamation-circle" theme="filled" :style="{ color: '#FFA031' }"></a-icon>
|
||||
<a-icon type="exclamation-circle" theme="filled"
|
||||
:style="{ color: '#FFA031' }"></a-icon>
|
||||
<span>{{ i18n "pages.xray.ipv4RoutingDesc" }}</span>
|
||||
</template>
|
||||
</a-alert>
|
||||
|
|
@ -221,18 +274,22 @@
|
|||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.xray.ipv4Routing" }}</template>
|
||||
<template #control>
|
||||
<a-select mode="tags" :style="{ width: '100%' }" v-model="ipv4Domains"
|
||||
<a-select mode="tags" :style="{ width: '100%' }"
|
||||
v-model="ipv4Domains"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option :value="p.value" :label="p.label" v-for="p in settingsData.ServicesOptions">
|
||||
<a-select-option :value="p.value" :label="p.label"
|
||||
v-for="p in settingsData.ServicesOptions">
|
||||
<span>[[ p.label ]]</span>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-row :xs="24" :sm="24" :lg="12">
|
||||
<a-alert type="warning" :style="{ textAlign: 'center', marginTop: '20px' }">
|
||||
<a-alert type="warning"
|
||||
:style="{ textAlign: 'center', marginTop: '20px' }">
|
||||
<template slot="message">
|
||||
<a-icon type="exclamation-circle" theme="filled" :style="{ color: '#FFA031' }"></a-icon>
|
||||
<a-icon type="exclamation-circle" theme="filled"
|
||||
:style="{ color: '#FFA031' }"></a-icon>
|
||||
{{ i18n "pages.xray.warpRoutingDesc" }}
|
||||
</template>
|
||||
</a-alert>
|
||||
|
|
@ -241,20 +298,24 @@
|
|||
<template #title>{{ i18n "pages.xray.warpRouting" }}</template>
|
||||
<template #control>
|
||||
<template v-if="WarpExist">
|
||||
<a-select mode="tags" :style="{ width: '100%' }" v-model="warpDomains"
|
||||
<a-select mode="tags" :style="{ width: '100%' }"
|
||||
v-model="warpDomains"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option :value="p.value" :label="p.label" v-for="p in settingsData.ServicesOptions">
|
||||
<a-select-option :value="p.value" :label="p.label"
|
||||
v-for="p in settingsData.ServicesOptions">
|
||||
<span>[[ p.label ]]</span>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-button type="primary" icon="cloud" @click="showWarp()">WARP</a-button>
|
||||
<a-button type="primary" icon="cloud"
|
||||
@click="showWarp()">WARP</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
</a-collapse-panel>
|
||||
<a-collapse-panel key="6" header='{{ i18n "pages.settings.resetDefaultConfig"}}'>
|
||||
<a-collapse-panel key="6"
|
||||
header='{{ i18n "pages.settings.resetDefaultConfig"}}'>
|
||||
<a-space direction="horizontal" :style="{ padding: '0 20px' }">
|
||||
<a-button type="danger" @click="resetXrayConfigToDefault">
|
||||
<span>{{ i18n "pages.settings.resetDefaultConfig" }}</span>
|
||||
|
|
|
|||
|
|
@ -4,17 +4,22 @@
|
|||
<a-col :xs="12" :sm="12" :lg="12">
|
||||
<a-space direction="horizontal" size="small">
|
||||
<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="cloud"
|
||||
@click="showWarp()">WARP</a-button>
|
||||
</a-space>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="12" :lg="12" :style="{ textAlign: 'right' }">
|
||||
<a-button-group>
|
||||
<a-button icon="sync" @click="refreshOutboundTraffic()" :loading="refreshing"></a-button>
|
||||
<a-popconfirm placement="topRight" @confirm="resetOutboundTraffic(-1)"
|
||||
<a-button icon="sync" @click="refreshOutboundTraffic()"
|
||||
:loading="refreshing"></a-button>
|
||||
<a-popconfirm placement="topRight"
|
||||
@confirm="resetOutboundTraffic(-1)"
|
||||
title='{{ i18n "pages.inbounds.resetTrafficContent"}}'
|
||||
:overlay-class-name="themeSwitcher.currentTheme" ok-text='{{ i18n "reset"}}'
|
||||
:overlay-class-name="themeSwitcher.currentTheme"
|
||||
ok-text='{{ i18n "reset"}}'
|
||||
cancel-text='{{ i18n "cancel"}}'>
|
||||
<a-icon slot="icon" type="question-circle-o"
|
||||
:style="{ color: themeSwitcher.isDarkTheme ? '#008771' : '#008771' }"></a-icon>
|
||||
|
|
@ -23,8 +28,10 @@
|
|||
</a-button-group>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-table :columns="outboundColumns" bordered :row-key="r => r.key" :data-source="outboundData"
|
||||
:scroll="isMobile ? {} : { x: 800 }" :pagination="false" :indent-size="0"
|
||||
<a-table :columns="outboundColumns" bordered :row-key="r => r.key"
|
||||
:data-source="outboundData"
|
||||
:scroll="isMobile ? {} : { x: 800 }" :pagination="false"
|
||||
:indent-size="0"
|
||||
:locale='{ filterConfirm: `{{ i18n "confirm" }}`, filterReset: `{{ i18n "reset" }}` }'>
|
||||
<template slot="action" slot-scope="text, outbound, index">
|
||||
<span>[[ index+1 ]]</span>
|
||||
|
|
@ -32,7 +39,8 @@
|
|||
<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 v-if="index>0" @click="setFirstOutbound(index)">
|
||||
<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>
|
||||
|
|
@ -56,21 +64,64 @@
|
|||
</a-dropdown>
|
||||
</template>
|
||||
<template slot="address" slot-scope="text, outbound, index">
|
||||
<p :style="{ margin: '0 5px' }" v-for="addr in findOutboundAddress(outbound)">[[ addr ]]</p>
|
||||
<p :style="{ margin: '0 5px' }"
|
||||
v-for="addr in findOutboundAddress(outbound)">[[ addr ]]</p>
|
||||
</template>
|
||||
<template slot="protocol" slot-scope="text, outbound, index">
|
||||
<a-tag :style="{ margin: '0' }" color="purple">[[ outbound.protocol ]]</a-tag>
|
||||
<a-tag :style="{ margin: '0' }" color="purple">[[ outbound.protocol
|
||||
]]</a-tag>
|
||||
<template
|
||||
v-if="[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(outbound.protocol)">
|
||||
<a-tag :style="{ margin: '0' }" color="blue">[[ outbound.streamSettings.network ]]</a-tag>
|
||||
<a-tag :style="{ margin: '0' }" v-if="outbound.streamSettings.security=='tls'" color="green">tls</a-tag>
|
||||
<a-tag :style="{ margin: '0' }" v-if="outbound.streamSettings.security=='reality'"
|
||||
<a-tag :style="{ margin: '0' }" color="blue">[[
|
||||
outbound.streamSettings.network ]]</a-tag>
|
||||
<a-tag :style="{ margin: '0' }"
|
||||
v-if="outbound.streamSettings.security=='tls'"
|
||||
color="green">tls</a-tag>
|
||||
<a-tag :style="{ margin: '0' }"
|
||||
v-if="outbound.streamSettings.security=='reality'"
|
||||
color="green">reality</a-tag>
|
||||
</template>
|
||||
</template>
|
||||
<template slot="traffic" slot-scope="text, outbound, index">
|
||||
<a-tag color="green">[[ findOutboundTraffic(outbound) ]]</a-tag>
|
||||
</template>
|
||||
<template slot="test" slot-scope="text, outbound, index">
|
||||
<a-tooltip>
|
||||
<template slot="title">{{ i18n "pages.xray.outbound.test"
|
||||
}}</template>
|
||||
<a-button
|
||||
type="primary"
|
||||
shape="circle"
|
||||
icon="thunderbolt"
|
||||
:loading="outboundTestStates[index] && outboundTestStates[index].testing"
|
||||
@click="testOutbound(index)"
|
||||
:disabled="(outbound.protocol === 'blackhole' || outbound.tag === 'blocked') || (outboundTestStates[index] && outboundTestStates[index].testing)">
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<template slot="testResult" slot-scope="text, outbound, index">
|
||||
<div
|
||||
v-if="outboundTestStates[index] && outboundTestStates[index].result">
|
||||
<a-tag v-if="outboundTestStates[index].result.success"
|
||||
color="green">
|
||||
[[ outboundTestStates[index].result.delay ]]ms
|
||||
<span v-if="outboundTestStates[index].result.statusCode">
|
||||
([[ outboundTestStates[index].result.statusCode
|
||||
]])</span>
|
||||
</a-tag>
|
||||
<a-tooltip v-else
|
||||
:title="outboundTestStates[index].result.error">
|
||||
<a-tag color="red">
|
||||
Failed
|
||||
</a-tag>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
<span
|
||||
v-else-if="outboundTestStates[index] && outboundTestStates[index].testing">
|
||||
<a-icon type="loading" />
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-space>
|
||||
{{end}}
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
{{ template "page/head_start" .}}
|
||||
<link rel="stylesheet" href="{{ .base_path }}assets/codemirror/codemirror.min.css?{{ .cur_ver }}">
|
||||
<link rel="stylesheet" href="{{ .base_path }}assets/codemirror/fold/foldgutter.css">
|
||||
<link rel="stylesheet" href="{{ .base_path }}assets/codemirror/xq.min.css?{{ .cur_ver }}">
|
||||
<link rel="stylesheet"
|
||||
href="{{ .base_path }}assets/codemirror/codemirror.min.css?{{ .cur_ver }}">
|
||||
<link rel="stylesheet"
|
||||
href="{{ .base_path }}assets/codemirror/fold/foldgutter.css">
|
||||
<link rel="stylesheet"
|
||||
href="{{ .base_path }}assets/codemirror/xq.min.css?{{ .cur_ver }}">
|
||||
<link rel="stylesheet" href="{{ .base_path }}assets/codemirror/lint/lint.css">
|
||||
{{ template "page/head_end" .}}
|
||||
|
||||
|
|
@ -10,10 +13,13 @@
|
|||
<a-sidebar></a-sidebar>
|
||||
<a-layout id="content-layout">
|
||||
<a-layout-content>
|
||||
<a-spin :spinning="loadingStates.spinning" :delay="500" tip='{{ i18n "loading"}}'>
|
||||
<a-spin :spinning="loadingStates.spinning" :delay="500"
|
||||
tip='{{ i18n "loading"}}'>
|
||||
<transition name="list" appear>
|
||||
<a-alert type="error" v-if="showAlert && loadingStates.fetched" :style="{ marginBottom: '10px' }"
|
||||
message='{{ i18n "secAlertTitle" }}' color="red" description='{{ i18n "secAlertSsl" }}' show-icon closable>
|
||||
<a-alert type="error" v-if="showAlert && loadingStates.fetched"
|
||||
:style="{ marginBottom: '10px' }"
|
||||
message='{{ i18n "secAlertTitle" }}' color="red"
|
||||
description='{{ i18n "secAlertSsl" }}' show-icon closable>
|
||||
</a-alert>
|
||||
</transition>
|
||||
<transition name="list" appear>
|
||||
|
|
@ -26,19 +32,25 @@
|
|||
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-else>
|
||||
<a-col>
|
||||
<a-card hoverable>
|
||||
<a-row :style="{ display: 'flex', flexWrap: 'wrap', alignItems: 'center' }">
|
||||
<a-row
|
||||
:style="{ display: 'flex', flexWrap: 'wrap', alignItems: 'center' }">
|
||||
<a-col :xs="24" :sm="10" :style="{ padding: '4px' }">
|
||||
<a-space direction="horizontal">
|
||||
<a-button type="primary" :disabled="saveBtnDisable" @click="updateXraySetting">
|
||||
<a-button type="primary" :disabled="saveBtnDisable"
|
||||
@click="updateXraySetting">
|
||||
{{ i18n "pages.xray.save" }}
|
||||
</a-button>
|
||||
<a-button type="danger" :disabled="!saveBtnDisable" @click="restartXray">
|
||||
<a-button type="danger" :disabled="!saveBtnDisable"
|
||||
@click="restartXray">
|
||||
{{ i18n "pages.xray.restart" }}
|
||||
</a-button>
|
||||
<a-popover v-if="restartResult" :overlay-class-name="themeSwitcher.currentTheme">
|
||||
<span slot="title">{{ i18n "pages.index.xrayErrorPopoverTitle" }}</span>
|
||||
<a-popover v-if="restartResult"
|
||||
:overlay-class-name="themeSwitcher.currentTheme">
|
||||
<span slot="title">{{ i18n
|
||||
"pages.index.xrayErrorPopoverTitle" }}</span>
|
||||
<template slot="content">
|
||||
<span :style="{ maxWidth: '400px' }" v-for="line in restartResult.split('\n')">[[ line
|
||||
<span :style="{ maxWidth: '400px' }"
|
||||
v-for="line in restartResult.split('\n')">[[ line
|
||||
]]</span>
|
||||
</template>
|
||||
<a-icon type="question-circle"></a-icon>
|
||||
|
|
@ -48,10 +60,13 @@
|
|||
<a-col :xs="24" :sm="14">
|
||||
<template>
|
||||
<div>
|
||||
<a-back-top :target="() => document.getElementById('content-layout')"
|
||||
<a-back-top
|
||||
:target="() => document.getElementById('content-layout')"
|
||||
visibility-height="200"></a-back-top>
|
||||
<a-alert type="warning" :style="{ float: 'right', width: 'fit-content' }"
|
||||
message='{{ i18n "pages.settings.infoDesc" }}' show-icon>
|
||||
<a-alert type="warning"
|
||||
:style="{ float: 'right', width: 'fit-content' }"
|
||||
message='{{ i18n "pages.settings.infoDesc" }}'
|
||||
show-icon>
|
||||
</a-alert>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -60,7 +75,8 @@
|
|||
</a-card>
|
||||
</a-col>
|
||||
<a-col>
|
||||
<a-tabs default-active-key="tpl-basic" @change="(activeKey) => { this.changePage(activeKey); }"
|
||||
<a-tabs default-active-key="tpl-basic"
|
||||
@change="(activeKey) => { this.changePage(activeKey); }"
|
||||
:class="themeSwitcher.currentTheme">
|
||||
<a-tab-pane key="tpl-basic" :style="{ paddingTop: '20px' }">
|
||||
<template #tab>
|
||||
|
|
@ -83,21 +99,24 @@
|
|||
</template>
|
||||
{{ template "settings/xray/outbounds" . }}
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="tpl-reverse" :style="{ paddingTop: '20px' }" force-render="true">
|
||||
<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>
|
||||
<a-icon type="cluster"></a-icon>
|
||||
<span>{{ i18n "pages.xray.Balancers"}}</span>
|
||||
</template>
|
||||
{{ template "settings/xray/balancers" . }}
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="tpl-dns" :style="{ paddingTop: '20px' }" force-render="true">
|
||||
<a-tab-pane key="tpl-dns" :style="{ paddingTop: '20px' }"
|
||||
force-render="true">
|
||||
<template #tab>
|
||||
<a-icon type="database"></a-icon>
|
||||
<span>DNS</span>
|
||||
|
|
@ -120,14 +139,18 @@
|
|||
</a-layout>
|
||||
</a-layout>
|
||||
{{template "page/body_scripts" .}}
|
||||
<script src="{{ .base_path }}assets/js/model/outbound.js?{{ .cur_ver }}"></script>
|
||||
<script src="{{ .base_path }}assets/codemirror/codemirror.min.js?{{ .cur_ver }}"></script>
|
||||
<script
|
||||
src="{{ .base_path }}assets/js/model/outbound.js?{{ .cur_ver }}"></script>
|
||||
<script
|
||||
src="{{ .base_path }}assets/codemirror/codemirror.min.js?{{ .cur_ver }}"></script>
|
||||
<script src="{{ .base_path }}assets/codemirror/javascript.js"></script>
|
||||
<script src="{{ .base_path }}assets/codemirror/jshint.js"></script>
|
||||
<script src="{{ .base_path }}assets/codemirror/jsonlint.js"></script>
|
||||
<script src="{{ .base_path }}assets/codemirror/lint/lint.js"></script>
|
||||
<script src="{{ .base_path }}assets/codemirror/lint/javascript-lint.js"></script>
|
||||
<script src="{{ .base_path }}assets/codemirror/hint/javascript-hint.js"></script>
|
||||
<script
|
||||
src="{{ .base_path }}assets/codemirror/lint/javascript-lint.js"></script>
|
||||
<script
|
||||
src="{{ .base_path }}assets/codemirror/hint/javascript-hint.js"></script>
|
||||
<script src="{{ .base_path }}assets/codemirror/fold/foldcode.js"></script>
|
||||
<script src="{{ .base_path }}assets/codemirror/fold/foldgutter.js"></script>
|
||||
<script src="{{ .base_path }}assets/codemirror/fold/brace-fold.js"></script>
|
||||
|
|
@ -181,11 +204,13 @@
|
|||
];
|
||||
|
||||
const outboundColumns = [
|
||||
{ title: "#", align: 'center', width: 20, scopedSlots: { customRender: 'action' } },
|
||||
{ title: "#", align: 'center', width: 60, scopedSlots: { customRender: 'action' } },
|
||||
{ title: '{{ i18n "pages.xray.outbound.tag"}}', dataIndex: 'tag', align: 'center', width: 50 },
|
||||
{ title: '{{ i18n "protocol"}}', align: 'center', width: 50, scopedSlots: { customRender: 'protocol' } },
|
||||
{ title: '{{ i18n "pages.xray.outbound.address"}}', align: 'center', width: 50, scopedSlots: { customRender: 'address' } },
|
||||
{ title: '{{ i18n "pages.inbounds.traffic" }}', align: 'center', width: 50, scopedSlots: { customRender: 'traffic' } },
|
||||
{ title: '{{ i18n "pages.xray.outbound.testResult" }}', align: 'center', width: 120, scopedSlots: { customRender: 'testResult' } },
|
||||
{ title: '{{ i18n "pages.xray.outbound.test" }}', align: 'center', width: 60, scopedSlots: { customRender: 'test' } },
|
||||
];
|
||||
|
||||
const reverseColumns = [
|
||||
|
|
@ -228,8 +253,11 @@
|
|||
},
|
||||
oldXraySetting: '',
|
||||
xraySetting: '',
|
||||
outboundTestUrl: 'https://www.google.com/generate_204',
|
||||
oldOutboundTestUrl: 'https://www.google.com/generate_204',
|
||||
inboundTags: [],
|
||||
outboundsTraffic: [],
|
||||
outboundTestStates: {}, // Track testing state and results for each outbound
|
||||
saveBtnDisable: true,
|
||||
refreshing: false,
|
||||
restartResult: '',
|
||||
|
|
@ -337,14 +365,14 @@
|
|||
},
|
||||
defaultObservatory: {
|
||||
subjectSelector: [],
|
||||
probeURL: "http://www.google.com/gen_204",
|
||||
probeURL: "https://www.google.com/generate_204",
|
||||
probeInterval: "10m",
|
||||
enableConcurrency: true
|
||||
},
|
||||
defaultBurstObservatory: {
|
||||
subjectSelector: [],
|
||||
pingConfig: {
|
||||
destination: "http://www.google.com/gen_204",
|
||||
destination: "https://www.google.com/generate_204",
|
||||
interval: "30m",
|
||||
connectivity: "http://connectivitycheck.platform.hicloud.com/generate_204",
|
||||
timeout: "10s",
|
||||
|
|
@ -375,12 +403,17 @@
|
|||
this.oldXraySetting = xs;
|
||||
this.xraySetting = xs;
|
||||
this.inboundTags = result.inboundTags;
|
||||
this.outboundTestUrl = result.outboundTestUrl || 'https://www.google.com/generate_204';
|
||||
this.oldOutboundTestUrl = this.outboundTestUrl;
|
||||
this.saveBtnDisable = true;
|
||||
}
|
||||
},
|
||||
async updateXraySetting() {
|
||||
this.loading(true);
|
||||
const msg = await HttpUtil.post("/panel/xray/update", { xraySetting: this.xraySetting });
|
||||
const msg = await HttpUtil.post("/panel/xray/update", {
|
||||
xraySetting: this.xraySetting,
|
||||
outboundTestUrl: this.outboundTestUrl || 'https://www.google.com/generate_204'
|
||||
});
|
||||
this.loading(false);
|
||||
if (msg.success) {
|
||||
await this.getXraySetting();
|
||||
|
|
@ -595,6 +628,73 @@
|
|||
outbounds.splice(0, 0, outbounds.splice(index, 1)[0]);
|
||||
this.outboundSettings = JSON.stringify(outbounds);
|
||||
},
|
||||
async testOutbound(index) {
|
||||
const outbound = this.templateSettings.outbounds[index];
|
||||
if (!outbound) {
|
||||
Vue.prototype.$message.error('{{ i18n "pages.xray.outbound.testError" }}');
|
||||
return;
|
||||
}
|
||||
|
||||
if (outbound.protocol === 'blackhole' || outbound.tag === 'blocked') {
|
||||
Vue.prototype.$message.warning('{{ i18n "pages.xray.outbound.testError" }}: blocked/blackhole outbound');
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize test state for this outbound if not exists
|
||||
if (!this.outboundTestStates[index]) {
|
||||
this.$set(this.outboundTestStates, index, {
|
||||
testing: false,
|
||||
result: null
|
||||
});
|
||||
}
|
||||
|
||||
// Set testing state
|
||||
this.$set(this.outboundTestStates[index], 'testing', true);
|
||||
this.$set(this.outboundTestStates[index], 'result', null);
|
||||
|
||||
try {
|
||||
const outboundJSON = JSON.stringify(outbound);
|
||||
const testURL = this.outboundTestUrl || 'https://www.google.com/generate_204';
|
||||
const allOutboundsJSON = JSON.stringify(this.templateSettings.outbounds || []);
|
||||
|
||||
const msg = await HttpUtil.post("/panel/xray/testOutbound", {
|
||||
outbound: outboundJSON,
|
||||
testURL: testURL,
|
||||
allOutbounds: allOutboundsJSON
|
||||
});
|
||||
|
||||
// Update test state
|
||||
this.$set(this.outboundTestStates[index], 'testing', false);
|
||||
|
||||
if (msg.success && msg.obj) {
|
||||
const result = msg.obj;
|
||||
this.$set(this.outboundTestStates[index], 'result', result);
|
||||
|
||||
if (result.success) {
|
||||
Vue.prototype.$message.success(
|
||||
`{{ i18n "pages.xray.outbound.testSuccess" }}: ${result.delay}ms (${result.statusCode})`
|
||||
);
|
||||
} else {
|
||||
Vue.prototype.$message.error(
|
||||
`{{ i18n "pages.xray.outbound.testFailed" }}: ${result.error || 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.$set(this.outboundTestStates[index], 'result', {
|
||||
success: false,
|
||||
error: msg.msg || '{{ i18n "pages.xray.outbound.testError" }}'
|
||||
});
|
||||
Vue.prototype.$message.error(msg.msg || '{{ i18n "pages.xray.outbound.testError" }}');
|
||||
}
|
||||
} catch (error) {
|
||||
this.$set(this.outboundTestStates[index], 'testing', false);
|
||||
this.$set(this.outboundTestStates[index], 'result', {
|
||||
success: false,
|
||||
error: error.message || '{{ i18n "pages.xray.outbound.testError" }}'
|
||||
});
|
||||
Vue.prototype.$message.error('{{ i18n "pages.xray.outbound.testError" }}: ' + error.message);
|
||||
}
|
||||
},
|
||||
addReverse() {
|
||||
reverseModal.show({
|
||||
title: '{{ i18n "pages.xray.outbound.addReverse"}}',
|
||||
|
|
@ -981,7 +1081,7 @@
|
|||
|
||||
while (true) {
|
||||
await PromiseUtil.sleep(800);
|
||||
this.saveBtnDisable = this.oldXraySetting === this.xraySetting;
|
||||
this.saveBtnDisable = this.oldXraySetting === this.xraySetting && this.oldOutboundTestUrl === this.outboundTestUrl;
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ func (j *XrayTrafficJob) Run() {
|
|||
}
|
||||
|
||||
// Broadcast traffic update via WebSocket with accumulated values from database
|
||||
trafficUpdate := map[string]interface{}{
|
||||
trafficUpdate := map[string]any{
|
||||
"traffics": traffics,
|
||||
"clientTraffics": clientTraffics,
|
||||
"onlineClients": onlineClients,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,23 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v2/config"
|
||||
"github.com/mhsanaei/3x-ui/v2/database"
|
||||
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||
"github.com/mhsanaei/3x-ui/v2/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v2/util/json_util"
|
||||
"github.com/mhsanaei/3x-ui/v2/xray"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
|
@ -13,6 +27,9 @@ import (
|
|||
// It handles outbound traffic monitoring and statistics.
|
||||
type OutboundService struct{}
|
||||
|
||||
// testSemaphore limits concurrent outbound tests to prevent resource exhaustion.
|
||||
var testSemaphore sync.Mutex
|
||||
|
||||
func (s *OutboundService) AddTraffic(traffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (error, bool) {
|
||||
var err error
|
||||
db := database.GetDB()
|
||||
|
|
@ -100,3 +117,308 @@ func (s *OutboundService) ResetOutboundTraffic(tag string) error {
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestOutboundResult represents the result of testing an outbound
|
||||
type TestOutboundResult struct {
|
||||
Success bool `json:"success"`
|
||||
Delay int64 `json:"delay"` // Delay in milliseconds
|
||||
Error string `json:"error,omitempty"`
|
||||
StatusCode int `json:"statusCode,omitempty"`
|
||||
}
|
||||
|
||||
// TestOutbound tests an outbound by creating a temporary xray instance and measuring response time.
|
||||
// allOutboundsJSON must be a JSON array of all outbounds; they are copied into the test config unchanged.
|
||||
// Only the test inbound and a route rule (to the tested outbound tag) are added.
|
||||
func (s *OutboundService) TestOutbound(outboundJSON string, testURL string, allOutboundsJSON string) (*TestOutboundResult, error) {
|
||||
if testURL == "" {
|
||||
testURL = "https://www.google.com/generate_204"
|
||||
}
|
||||
|
||||
// Limit to one concurrent test at a time
|
||||
if !testSemaphore.TryLock() {
|
||||
return &TestOutboundResult{
|
||||
Success: false,
|
||||
Error: "Another outbound test is already running, please wait",
|
||||
}, nil
|
||||
}
|
||||
defer testSemaphore.Unlock()
|
||||
|
||||
// Parse the outbound being tested to get its tag
|
||||
var testOutbound map[string]any
|
||||
if err := json.Unmarshal([]byte(outboundJSON), &testOutbound); err != nil {
|
||||
return &TestOutboundResult{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("Invalid outbound JSON: %v", err),
|
||||
}, nil
|
||||
}
|
||||
outboundTag, _ := testOutbound["tag"].(string)
|
||||
if outboundTag == "" {
|
||||
return &TestOutboundResult{
|
||||
Success: false,
|
||||
Error: "Outbound has no tag",
|
||||
}, nil
|
||||
}
|
||||
if protocol, _ := testOutbound["protocol"].(string); protocol == "blackhole" || outboundTag == "blocked" {
|
||||
return &TestOutboundResult{
|
||||
Success: false,
|
||||
Error: "Blocked/blackhole outbound cannot be tested",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Use all outbounds when provided; otherwise fall back to single outbound
|
||||
var allOutbounds []any
|
||||
if allOutboundsJSON != "" {
|
||||
if err := json.Unmarshal([]byte(allOutboundsJSON), &allOutbounds); err != nil {
|
||||
return &TestOutboundResult{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("Invalid allOutbounds JSON: %v", err),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
if len(allOutbounds) == 0 {
|
||||
allOutbounds = []any{testOutbound}
|
||||
}
|
||||
|
||||
// Find an available port for test inbound
|
||||
testPort, err := findAvailablePort()
|
||||
if err != nil {
|
||||
return &TestOutboundResult{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("Failed to find available port: %v", err),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Copy all outbounds as-is, add only test inbound and route rule
|
||||
testConfig := s.createTestConfig(outboundTag, allOutbounds, testPort)
|
||||
|
||||
// Use a temporary config file so the main config.json is never overwritten
|
||||
testConfigPath, err := createTestConfigPath()
|
||||
if err != nil {
|
||||
return &TestOutboundResult{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("Failed to create test config path: %v", err),
|
||||
}, nil
|
||||
}
|
||||
defer os.Remove(testConfigPath) // ensure temp file is removed even if process is not stopped
|
||||
|
||||
// Create temporary xray process with its own config file
|
||||
testProcess := xray.NewTestProcess(testConfig, testConfigPath)
|
||||
defer func() {
|
||||
if testProcess.IsRunning() {
|
||||
testProcess.Stop()
|
||||
}
|
||||
}()
|
||||
|
||||
// Start the test process
|
||||
if err := testProcess.Start(); err != nil {
|
||||
return &TestOutboundResult{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("Failed to start test xray instance: %v", err),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Wait for xray to start listening on the test port
|
||||
if err := waitForPort(testPort, 3*time.Second); err != nil {
|
||||
if !testProcess.IsRunning() {
|
||||
result := testProcess.GetResult()
|
||||
return &TestOutboundResult{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("Xray process exited: %s", result),
|
||||
}, nil
|
||||
}
|
||||
return &TestOutboundResult{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("Xray failed to start listening: %v", err),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Check if process is still running
|
||||
if !testProcess.IsRunning() {
|
||||
result := testProcess.GetResult()
|
||||
return &TestOutboundResult{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("Xray process exited: %s", result),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Test the connection through proxy
|
||||
delay, statusCode, err := s.testConnection(testPort, testURL)
|
||||
if err != nil {
|
||||
return &TestOutboundResult{
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &TestOutboundResult{
|
||||
Success: true,
|
||||
Delay: delay,
|
||||
StatusCode: statusCode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// createTestConfig creates a test config by copying all outbounds unchanged and adding
|
||||
// only the test inbound (SOCKS) and a route rule that sends traffic to the given outbound tag.
|
||||
func (s *OutboundService) createTestConfig(outboundTag string, allOutbounds []any, testPort int) *xray.Config {
|
||||
// Test inbound (SOCKS proxy) - only addition to inbounds
|
||||
testInbound := xray.InboundConfig{
|
||||
Tag: "test-inbound",
|
||||
Listen: json_util.RawMessage(`"127.0.0.1"`),
|
||||
Port: testPort,
|
||||
Protocol: "socks",
|
||||
Settings: json_util.RawMessage(`{"auth":"noauth","udp":true}`),
|
||||
}
|
||||
|
||||
// Outbounds: copy all, but set noKernelTun=true for WireGuard outbounds
|
||||
processedOutbounds := make([]any, len(allOutbounds))
|
||||
for i, ob := range allOutbounds {
|
||||
outbound, ok := ob.(map[string]any)
|
||||
if !ok {
|
||||
processedOutbounds[i] = ob
|
||||
continue
|
||||
}
|
||||
if protocol, ok := outbound["protocol"].(string); ok && protocol == "wireguard" {
|
||||
// Set noKernelTun to true for WireGuard outbounds
|
||||
if settings, ok := outbound["settings"].(map[string]any); ok {
|
||||
settings["noKernelTun"] = true
|
||||
} else {
|
||||
// Create settings if it doesn't exist
|
||||
outbound["settings"] = map[string]any{
|
||||
"noKernelTun": true,
|
||||
}
|
||||
}
|
||||
}
|
||||
processedOutbounds[i] = outbound
|
||||
}
|
||||
outboundsJSON, _ := json.Marshal(processedOutbounds)
|
||||
|
||||
// Create routing rule to route all traffic through test outbound
|
||||
routingRules := []map[string]any{
|
||||
{
|
||||
"type": "field",
|
||||
"outboundTag": outboundTag,
|
||||
"network": "tcp,udp",
|
||||
},
|
||||
}
|
||||
|
||||
routingJSON, _ := json.Marshal(map[string]any{
|
||||
"domainStrategy": "AsIs",
|
||||
"rules": routingRules,
|
||||
})
|
||||
|
||||
// Disable logging for test process to avoid creating orphaned log files
|
||||
logConfig := map[string]any{
|
||||
"loglevel": "warning",
|
||||
"access": "none",
|
||||
"error": "none",
|
||||
"dnsLog": false,
|
||||
}
|
||||
logJSON, _ := json.Marshal(logConfig)
|
||||
|
||||
// Create minimal config
|
||||
cfg := &xray.Config{
|
||||
LogConfig: json_util.RawMessage(logJSON),
|
||||
InboundConfigs: []xray.InboundConfig{
|
||||
testInbound,
|
||||
},
|
||||
OutboundConfigs: json_util.RawMessage(string(outboundsJSON)),
|
||||
RouterConfig: json_util.RawMessage(string(routingJSON)),
|
||||
Policy: json_util.RawMessage(`{}`),
|
||||
Stats: json_util.RawMessage(`{}`),
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
// testConnection tests the connection through the proxy and measures delay.
|
||||
// It performs a warmup request first to establish the SOCKS connection and populate DNS caches,
|
||||
// then measures the second request for a more accurate latency reading.
|
||||
func (s *OutboundService) testConnection(proxyPort int, testURL string) (int64, int, error) {
|
||||
// Create SOCKS5 proxy URL
|
||||
proxyURL := fmt.Sprintf("socks5://127.0.0.1:%d", proxyPort)
|
||||
|
||||
// Parse proxy URL
|
||||
proxyURLParsed, err := url.Parse(proxyURL)
|
||||
if err != nil {
|
||||
return 0, 0, common.NewErrorf("Invalid proxy URL: %v", err)
|
||||
}
|
||||
|
||||
// Create HTTP client with proxy and keep-alive for connection reuse
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyURL(proxyURLParsed),
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 5 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
MaxIdleConns: 1,
|
||||
IdleConnTimeout: 10 * time.Second,
|
||||
DisableCompression: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Warmup request: establishes SOCKS/TLS connection, DNS, and TCP to the target.
|
||||
// This mirrors real-world usage where connections are reused.
|
||||
warmupResp, err := client.Get(testURL)
|
||||
if err != nil {
|
||||
return 0, 0, common.NewErrorf("Request failed: %v", err)
|
||||
}
|
||||
io.Copy(io.Discard, warmupResp.Body)
|
||||
warmupResp.Body.Close()
|
||||
|
||||
// Measure the actual request on the warm connection
|
||||
startTime := time.Now()
|
||||
resp, err := client.Get(testURL)
|
||||
delay := time.Since(startTime).Milliseconds()
|
||||
|
||||
if err != nil {
|
||||
return 0, 0, common.NewErrorf("Request failed: %v", err)
|
||||
}
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
return delay, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// waitForPort polls until the given TCP port is accepting connections or the timeout expires.
|
||||
func waitForPort(port int, timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 100*time.Millisecond)
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
return nil
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
return fmt.Errorf("port %d not ready after %v", port, timeout)
|
||||
}
|
||||
|
||||
// findAvailablePort finds an available port for testing
|
||||
func findAvailablePort() (int, error) {
|
||||
listener, err := net.Listen("tcp", ":0")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
addr := listener.Addr().(*net.TCPAddr)
|
||||
return addr.Port, nil
|
||||
}
|
||||
|
||||
// createTestConfigPath returns a unique path for a temporary xray config file in the bin folder.
|
||||
// The temp file is created and closed so the path is reserved; Start() will overwrite it.
|
||||
func createTestConfigPath() (string, error) {
|
||||
tmpFile, err := os.CreateTemp(config.GetBinFolderPath(), "xray_test_*.json")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path := tmpFile.Name()
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
os.Remove(path)
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,6 +78,8 @@ var defaultValueMap = map[string]string{
|
|||
"warp": "",
|
||||
"externalTrafficInformEnable": "false",
|
||||
"externalTrafficInformURI": "",
|
||||
"xrayOutboundTestUrl": "https://www.google.com/generate_204",
|
||||
|
||||
// LDAP defaults
|
||||
"ldapEnable": "false",
|
||||
"ldapHost": "",
|
||||
|
|
@ -271,6 +273,14 @@ func (s *SettingService) GetXrayConfigTemplate() (string, error) {
|
|||
return s.getString("xrayTemplateConfig")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetXrayOutboundTestUrl() (string, error) {
|
||||
return s.getString("xrayOutboundTestUrl")
|
||||
}
|
||||
|
||||
func (s *SettingService) SetXrayOutboundTestUrl(url string) error {
|
||||
return s.setString("xrayOutboundTestUrl", url)
|
||||
}
|
||||
|
||||
func (s *SettingService) GetListen() (string, error) {
|
||||
return s.getString("webListen")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -460,6 +460,8 @@
|
|||
"FreedomStrategyDesc" = "اختار استراتيجية المخرجات للشبكة في بروتوكول الحرية."
|
||||
"RoutingStrategy" = "استراتيجية التوجيه العامة"
|
||||
"RoutingStrategyDesc" = "حدد استراتيجية التوجيه الإجمالية لحل كل الطلبات."
|
||||
"outboundTestUrl" = "رابط اختبار المخرج"
|
||||
"outboundTestUrlDesc" = "الرابط المستخدم عند اختبار اتصال المخرج"
|
||||
"Torrent" = "حظر بروتوكول التورنت"
|
||||
"Inbounds" = "الإدخالات"
|
||||
"InboundsDesc" = "قبول العملاء المعينين."
|
||||
|
|
|
|||
|
|
@ -460,6 +460,8 @@
|
|||
"FreedomStrategyDesc" = "Set the output strategy for the network in the Freedom Protocol."
|
||||
"RoutingStrategy" = "Overall Routing Strategy"
|
||||
"RoutingStrategyDesc" = "Set the overall traffic routing strategy for resolving all requests."
|
||||
"outboundTestUrl" = "Outbound Test URL"
|
||||
"outboundTestUrlDesc" = "URL used when testing outbound connectivity."
|
||||
"Torrent" = "Block BitTorrent Protocol"
|
||||
"Inbounds" = "Inbounds"
|
||||
"InboundsDesc" = "Accepting the specific clients."
|
||||
|
|
@ -523,6 +525,12 @@
|
|||
"accountInfo" = "Account Information"
|
||||
"outboundStatus" = "Outbound Status"
|
||||
"sendThrough" = "Send Through"
|
||||
"test" = "Test"
|
||||
"testResult" = "Test Result"
|
||||
"testing" = "Testing connection..."
|
||||
"testSuccess" = "Test successful"
|
||||
"testFailed" = "Test failed"
|
||||
"testError" = "Failed to test outbound"
|
||||
|
||||
[pages.xray.balancer]
|
||||
"addBalancer" = "Add Balancer"
|
||||
|
|
|
|||
|
|
@ -460,6 +460,8 @@
|
|||
"FreedomStrategyDesc" = "Establece la estrategia de salida de la red en el Protocolo Freedom."
|
||||
"RoutingStrategy" = "Configurar Estrategia de Enrutamiento de Dominios"
|
||||
"RoutingStrategyDesc" = "Establece la estrategia general de enrutamiento para la resolución de DNS."
|
||||
"outboundTestUrl" = "URL de prueba de outbound"
|
||||
"outboundTestUrlDesc" = "URL usada al probar la conectividad del outbound"
|
||||
"Torrent" = "Prohibir Uso de BitTorrent"
|
||||
"Inbounds" = "Entrante"
|
||||
"InboundsDesc" = "Cambia la plantilla de configuración para aceptar clientes específicos."
|
||||
|
|
|
|||
|
|
@ -460,6 +460,8 @@
|
|||
"FreedomStrategyDesc" = "تعیین میکند Freedom استراتژی خروجی شبکه را برای پروتکل"
|
||||
"RoutingStrategy" = "استراتژی کلی مسیریابی"
|
||||
"RoutingStrategyDesc" = "استراتژی کلی مسیریابی برای حل تمام درخواستها را تعیین میکند"
|
||||
"outboundTestUrl" = "آدرس تست خروجی"
|
||||
"outboundTestUrlDesc" = "آدرسی که برای تست اتصال خروجی استفاده میشود."
|
||||
"Torrent" = "مسدودسازی پروتکل بیتتورنت"
|
||||
"Inbounds" = "ورودیها"
|
||||
"InboundsDesc" = "پذیرش کلاینت خاص"
|
||||
|
|
|
|||
|
|
@ -460,6 +460,8 @@
|
|||
"FreedomStrategyDesc" = "Atur strategi output untuk jaringan dalam Protokol Freedom."
|
||||
"RoutingStrategy" = "Strategi Pengalihan Keseluruhan"
|
||||
"RoutingStrategyDesc" = "Atur strategi pengalihan lalu lintas keseluruhan untuk menyelesaikan semua permintaan."
|
||||
"outboundTestUrl" = "URL tes outbound"
|
||||
"outboundTestUrlDesc" = "URL yang digunakan saat menguji konektivitas outbound"
|
||||
"Torrent" = "Blokir Protokol BitTorrent"
|
||||
"Inbounds" = "Masuk"
|
||||
"InboundsDesc" = "Menerima klien tertentu."
|
||||
|
|
|
|||
|
|
@ -460,6 +460,8 @@
|
|||
"FreedomStrategyDesc" = "Freedomプロトコル内のネットワークの出力戦略を設定する"
|
||||
"RoutingStrategy" = "ルーティングドメイン戦略設定"
|
||||
"RoutingStrategyDesc" = "DNS解決の全体的なルーティング戦略を設定する"
|
||||
"outboundTestUrl" = "アウトバウンドテスト URL"
|
||||
"outboundTestUrlDesc" = "アウトバウンド接続テストに使用する URL。既定値"
|
||||
"Torrent" = "BitTorrent プロトコルをブロック"
|
||||
"Inbounds" = "インバウンドルール"
|
||||
"InboundsDesc" = "特定のクライアントからのトラフィックを受け入れる"
|
||||
|
|
|
|||
|
|
@ -460,6 +460,8 @@
|
|||
"FreedomStrategyDesc" = "Definir a estratégia de saída para a rede no Protocolo Freedom."
|
||||
"RoutingStrategy" = "Estratégia Geral de Roteamento"
|
||||
"RoutingStrategyDesc" = "Definir a estratégia geral de roteamento de tráfego para resolver todas as solicitações."
|
||||
"outboundTestUrl" = "URL de teste de outbound"
|
||||
"outboundTestUrlDesc" = "URL usada ao testar conectividade do outbound"
|
||||
"Torrent" = "Bloquear Protocolo BitTorrent"
|
||||
"Inbounds" = "Inbounds"
|
||||
"InboundsDesc" = "Aceitar clientes específicos."
|
||||
|
|
|
|||
|
|
@ -460,6 +460,8 @@
|
|||
"FreedomStrategyDesc" = "Установка стратегии вывода сети в протоколе Freedom"
|
||||
"RoutingStrategy" = "Настройка маршрутизации доменов"
|
||||
"RoutingStrategyDesc" = "Установка общей стратегии маршрутизации разрешения DNS"
|
||||
"outboundTestUrl" = "URL для теста исходящего"
|
||||
"outboundTestUrlDesc" = "URL для проверки подключения исходящего"
|
||||
"Torrent" = "Заблокировать BitTorrent"
|
||||
"Inbounds" = "Входящие подключения"
|
||||
"InboundsDesc" = "Изменение шаблона конфигурации для подключения определенных клиентов"
|
||||
|
|
|
|||
|
|
@ -460,6 +460,8 @@
|
|||
"FreedomStrategyDesc" = "Freedom Protokolünde ağın çıkış stratejisini ayarlayın."
|
||||
"RoutingStrategy" = "Genel Yönlendirme Stratejisi"
|
||||
"RoutingStrategyDesc" = "Tüm istekleri çözmek için genel trafik yönlendirme stratejisini ayarlayın."
|
||||
"outboundTestUrl" = "Outbound test URL"
|
||||
"outboundTestUrlDesc" = "Outbound bağlantı testinde kullanılan URL"
|
||||
"Torrent" = "BitTorrent Protokolünü Engelle"
|
||||
"Inbounds" = "Gelenler"
|
||||
"InboundsDesc" = "Belirli müşterileri kabul eder."
|
||||
|
|
|
|||
|
|
@ -460,6 +460,8 @@
|
|||
"FreedomStrategyDesc" = "Установити стратегію виведення для мережі в протоколі свободи."
|
||||
"RoutingStrategy" = "Загальна стратегія маршрутизації"
|
||||
"RoutingStrategyDesc" = "Установити загальну стратегію маршрутизації трафіку для вирішення всіх запитів."
|
||||
"outboundTestUrl" = "URL тесту outbound"
|
||||
"outboundTestUrlDesc" = "URL для перевірки з'єднання outbound"
|
||||
"Torrent" = "Блокувати протокол BitTorrent"
|
||||
"Inbounds" = "Вхідні"
|
||||
"InboundsDesc" = "Прийняття певних клієнтів."
|
||||
|
|
|
|||
|
|
@ -460,6 +460,8 @@
|
|||
"FreedomStrategyDesc" = "Đặt chiến lược đầu ra của mạng trong Giao thức Freedom."
|
||||
"RoutingStrategy" = "Cấu hình Chiến lược Định tuyến Tên miền"
|
||||
"RoutingStrategyDesc" = "Đặt chiến lược định tuyến tổng thể cho việc giải quyết DNS."
|
||||
"outboundTestUrl" = "URL kiểm tra outbound"
|
||||
"outboundTestUrlDesc" = "URL dùng khi kiểm tra kết nối outbound"
|
||||
"Torrent" = "Cấu hình sử dụng BitTorrent"
|
||||
"Inbounds" = "Đầu vào"
|
||||
"InboundsDesc" = "Thay đổi mẫu cấu hình để chấp nhận các máy khách cụ thể."
|
||||
|
|
|
|||
|
|
@ -460,6 +460,8 @@
|
|||
"FreedomStrategyDesc" = "设置 Freedom 协议中网络的输出策略"
|
||||
"RoutingStrategy" = "配置路由域策略"
|
||||
"RoutingStrategyDesc" = "设置 DNS 解析的整体路由策略"
|
||||
"outboundTestUrl" = "出站测试 URL"
|
||||
"outboundTestUrlDesc" = "测试出站连接时使用的 URL"
|
||||
"Torrent" = "屏蔽 BitTorrent 协议"
|
||||
"Inbounds" = "入站规则"
|
||||
"InboundsDesc" = "接受来自特定客户端的流量"
|
||||
|
|
|
|||
|
|
@ -460,6 +460,8 @@
|
|||
"FreedomStrategyDesc" = "設定 Freedom 協議中網路的輸出策略"
|
||||
"RoutingStrategy" = "配置路由域策略"
|
||||
"RoutingStrategyDesc" = "設定 DNS 解析的整體路由策略"
|
||||
"outboundTestUrl" = "出站測試 URL"
|
||||
"outboundTestUrlDesc" = "測試出站連線時使用的 URL"
|
||||
"Torrent" = "遮蔽 BitTorrent 協議"
|
||||
"Inbounds" = "入站規則"
|
||||
"InboundsDesc" = "接受來自特定客戶端的流量"
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ func BroadcastInbounds(inbounds any) {
|
|||
}
|
||||
|
||||
// BroadcastOutbounds broadcasts outbounds list update to all connected clients
|
||||
func BroadcastOutbounds(outbounds interface{}) {
|
||||
func BroadcastOutbounds(outbounds any) {
|
||||
hub := GetHub()
|
||||
if hub != nil {
|
||||
hub.Broadcast(MessageTypeOutbounds, outbounds)
|
||||
|
|
|
|||
|
|
@ -110,6 +110,15 @@ func NewProcess(xrayConfig *Config) *Process {
|
|||
return p
|
||||
}
|
||||
|
||||
// NewTestProcess creates a new Xray process that uses a specific config file path.
|
||||
// Used for test runs (e.g. outbound test) so the main config.json is not overwritten.
|
||||
// The config file at configPath is removed when the process is stopped.
|
||||
func NewTestProcess(xrayConfig *Config, configPath string) *Process {
|
||||
p := &Process{newTestProcess(xrayConfig, configPath)}
|
||||
runtime.SetFinalizer(p, stopProcess)
|
||||
return p
|
||||
}
|
||||
|
||||
type process struct {
|
||||
cmd *exec.Cmd
|
||||
|
||||
|
|
@ -118,10 +127,11 @@ type process struct {
|
|||
|
||||
onlineClients []string
|
||||
|
||||
config *Config
|
||||
logWriter *LogWriter
|
||||
exitErr error
|
||||
startTime time.Time
|
||||
config *Config
|
||||
configPath string // if set, use this path instead of GetConfigPath() and remove on Stop
|
||||
logWriter *LogWriter
|
||||
exitErr error
|
||||
startTime time.Time
|
||||
}
|
||||
|
||||
// newProcess creates a new internal process struct for Xray.
|
||||
|
|
@ -134,6 +144,13 @@ func newProcess(config *Config) *process {
|
|||
}
|
||||
}
|
||||
|
||||
// newTestProcess creates a process that writes and runs with a specific config path.
|
||||
func newTestProcess(config *Config, configPath string) *process {
|
||||
p := newProcess(config)
|
||||
p.configPath = configPath
|
||||
return p
|
||||
}
|
||||
|
||||
// IsRunning returns true if the Xray process is currently running.
|
||||
func (p *process) IsRunning() bool {
|
||||
if p.cmd == nil || p.cmd.Process == nil {
|
||||
|
|
@ -238,6 +255,9 @@ func (p *process) Start() (err error) {
|
|||
}
|
||||
|
||||
configPath := GetConfigPath()
|
||||
if p.configPath != "" {
|
||||
configPath = p.configPath
|
||||
}
|
||||
err = os.WriteFile(configPath, data, fs.ModePerm)
|
||||
if err != nil {
|
||||
return common.NewErrorf("Failed to write configuration file: %v", err)
|
||||
|
|
@ -278,6 +298,16 @@ func (p *process) Stop() error {
|
|||
return errors.New("xray is not running")
|
||||
}
|
||||
|
||||
// Remove temporary config file used for test runs so main config is never touched
|
||||
if p.configPath != "" {
|
||||
if p.configPath != GetConfigPath() {
|
||||
// Check if file exists before removing
|
||||
if _, err := os.Stat(p.configPath); err == nil {
|
||||
_ = os.Remove(p.configPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
return p.cmd.Process.Kill()
|
||||
} else {
|
||||
|
|
|
|||
Loading…
Reference in a new issue