[feat] outbound test

This commit is contained in:
Alireza Ahmadi 2026-06-07 16:50:53 +02:00
parent a186b2e26e
commit 3fd2eb396b
14 changed files with 369 additions and 3 deletions

View file

@ -18,6 +18,9 @@ var name string
//go:embed default_xray.json
var defaultXrayTemplate string
//go:embed test_xray.json
var testXrayTemplate string
type LogLevel string
const (
@ -93,3 +96,7 @@ func GetDBPath() string {
func GetDefaultXrayTemplate() string {
return defaultXrayTemplate
}
func GetTestXrayTemplate() string {
return testXrayTemplate
}

17
config/test_xray.json Normal file
View file

@ -0,0 +1,17 @@
{
"log": {
"loglevel": "none"
},
"inbounds": [
{
"tag": "http-test-in",
"listen": "127.0.0.1",
"port": __INBOUND_PORT__,
"protocol": "http",
"settings": {}
}
],
"outbounds": [
__OUTBOUND__
]
}

View file

@ -12,6 +12,7 @@ class AllSetting {
this.expireDiff = "";
this.trafficDiff = "";
this.remarkModel = "-ieo";
this.outboundTestUrl = "https://www.gstatic.com/generate_204";
this.tgBotEnable = false;
this.tgBotToken = "";
this.tgBotChatId = "";

View file

@ -30,6 +30,7 @@ func (a *OutboundController) initRouter(g *gin.RouterGroup) {
g.POST("/:id/resetTraffic", a.resetTraffic)
g.POST("/resetAllTraffics", a.resetAllTraffics)
g.POST("/onlines", a.onlines)
g.POST("/test", a.test)
}
func (a *OutboundController) getOutbounds(c *gin.Context) {
@ -116,3 +117,13 @@ func (a *OutboundController) resetAllTraffics(c *gin.Context) {
func (a *OutboundController) onlines(c *gin.Context) {
jsonObj(c, a.outboundService.GetOnlineOutbounds(), nil)
}
func (a *OutboundController) test(c *gin.Context) {
id, err := strconv.Atoi(c.PostForm("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.outbounds.test"), err)
return
}
result, err := a.outboundService.TestOutbound(id)
jsonObj(c, result, err)
}

View file

@ -27,6 +27,7 @@ type AllSetting struct {
ExpireDiff int `json:"expireDiff" form:"expireDiff"`
TrafficDiff int `json:"trafficDiff" form:"trafficDiff"`
RemarkModel string `json:"remarkModel" form:"remarkModel"`
OutboundTestUrl string `json:"outboundTestUrl" form:"outboundTestUrl"`
TgBotEnable bool `json:"tgBotEnable" form:"tgBotEnable"`
TgBotToken string `json:"tgBotToken" form:"tgBotToken"`
TgBotChatId string `json:"tgBotChatId" form:"tgBotChatId"`

View file

@ -90,6 +90,10 @@
<template v-if="!isMobile">{{ i18n "pages.inbounds.generalActions" }}</template>
</a-button>
<a-menu slot="overlay" @click="a => generalActions(a)" :theme="themeSwitcher.currentTheme">
<a-menu-item key="testAll">
<a-icon type="thunderbolt"></a-icon>
{{ i18n "pages.outbounds.testAll" }}
</a-menu-item>
<a-menu-item key="resetTraffics">
<a-icon type="reload"></a-icon>
{{ i18n "pages.outbounds.resetAllTraffic" }}
@ -167,6 +171,29 @@
</a-popconfirm>
</a-tooltip>
</template>
<template slot="actionMenu" slot-scope="text, row, index">
<a-dropdown :trigger="['click']">
<a-icon @click="e => e.preventDefault()" type="ellipsis" style="font-size: 20px;"></a-icon>
<a-menu slot="overlay" :theme="themeSwitcher.currentTheme">
<a-menu-item v-if="index > 0" @click="setFirstOutbound(row.id)">
<a-icon style="font-size: 14px;" type="vertical-align-top"></a-icon>
{{ i18n "pages.xray.rules.first" }}
</a-menu-item>
<a-menu-item @click="editOutbound(row)">
<a-icon style="font-size: 14px;" type="edit"></a-icon>
{{ i18n "pages.outbounds.edit" }}
</a-menu-item>
<a-menu-item @click="resetTraffic(row.id)">
<a-icon style="font-size: 14px;" type="retweet"></a-icon>
{{ i18n "pages.outbounds.resetTraffic" }}
</a-menu-item>
<a-menu-item @click="deleteOutbound(row.id)">
<a-icon style="font-size: 14px;" type="delete"></a-icon>
<span style="color: #FF4D4F"> {{ i18n "delete" }}</span>
</a-menu-item>
</a-menu>
</a-dropdown>
</template>
<template slot="online" slot-scope="text, row">
<a-tag v-if="isOutboundOnline(row.tag)" color="green">{{ i18n "online" }}</a-tag>
<a-tag v-else>{{ i18n "offline" }}</a-tag>
@ -198,6 +225,65 @@
<template slot="traffic" slot-scope="text, row">
<a-tag color="blue">↑[[ sizeFormat(row.up) ]] / ↓[[ sizeFormat(row.down) ]]</a-tag>
</template>
<template slot="test" slot-scope="text, row">
<span style="display: inline-flex; align-items: center; justify-content: center; gap: 6px;">
<a-tooltip>
<template slot="title">{{ i18n "pages.outbounds.testTooltip" }}</template>
<a-icon style="font-size: 22px; cursor: pointer;" class="normal-icon"
:type="(testResults[row.id] && testResults[row.id].testing) ? 'loading' : 'thunderbolt'"
@click="testOutbound(row.id)"></a-icon>
</a-tooltip>
<template v-if="testResults[row.id] && !testResults[row.id].testing">
<a-tooltip v-if="testResults[row.id].success">
<template slot="title">{{ i18n "pages.outbounds.testSuccess" }}</template>
<a-tag color="green" style="margin: 0;">[[ testResults[row.id].delay ]] ms</a-tag>
</a-tooltip>
<a-tooltip v-else>
<template slot="title">[[ testResults[row.id].message || '{{ i18n "pages.outbounds.testFailed" }}' ]]</template>
<a-tag color="red" style="margin: 0;">{{ i18n "pages.outbounds.testFailed" }}</a-tag>
</a-tooltip>
</template>
</span>
</template>
<template slot="info" slot-scope="text, row">
<a-popover placement="bottomRight" :overlay-class-name="themeSwitcher.currentTheme" trigger="click">
<template slot="content">
<table cellpadding="2">
<tr>
<td>{{ i18n "online" }}</td>
<td>
<a-tag style="margin:0;" v-if="isOutboundOnline(row.tag)" color="green">{{ i18n "online" }}</a-tag>
<a-tag style="margin:0;" v-else>{{ i18n "offline" }}</a-tag>
</td>
</tr>
<tr>
<td>{{ i18n "protocol" }}</td>
<td>
<a-tag style="margin:0;" color="purple">[[ row.protocol ]]</a-tag>
<template v-if="[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(row.protocol)">
<a-tag style="margin:0;" color="blue">[[ row.toOutbound().stream.network ]]</a-tag>
<a-tag style="margin:0;" v-if="row.toOutbound().stream.isTls" color="green">tls</a-tag>
<a-tag style="margin:0;" v-if="row.toOutbound().stream.isReality" color="green">reality</a-tag>
</template>
</td>
</tr>
<tr>
<td>{{ i18n "pages.xray.outbound.address" }}</td>
<td>
<p style="margin: 0;" v-for="addr in findOutboundAddress(row.toOutbound().toJson())">[[ addr ]]</p>
</td>
</tr>
<tr>
<td>{{ i18n "pages.inbounds.traffic" }}</td>
<td><a-tag style="margin:0;" color="blue">↑[[ sizeFormat(row.up) ]] / ↓[[ sizeFormat(row.down) ]]</a-tag></td>
</tr>
</table>
</template>
<a-button shape="round" size="small" style="font-size: 14px; padding: 0 10px;">
<a-icon type="info"></a-icon>
</a-button>
</a-popover>
</template>
</a-table>
</a-card>
</transition>
@ -218,12 +304,13 @@
{ title: '{{ i18n "protocol"}}', align: 'center', width: 120, scopedSlots: { customRender: 'protocol' } },
{ title: '{{ i18n "pages.xray.outbound.address"}}', width: 150, align: 'center', scopedSlots: { customRender: 'address' } },
{ title: '{{ i18n "pages.inbounds.traffic" }}', align: 'center', width: 140, scopedSlots: { customRender: 'traffic' } },
{ title: '{{ i18n "pages.outbounds.test" }}', align: 'center', width: 120, scopedSlots: { customRender: 'test' } },
];
const mobileColumns = [
{ title: "ID", align: 'center', width: 30, dataIndex: 'id' },
{ title: '{{ i18n "pages.inbounds.operate" }}', align: 'center', width: 110, scopedSlots: { customRender: 'actions' } },
{ title: '', align: 'center', width: 10, scopedSlots: { customRender: 'actionMenu' } },
{ title: '{{ i18n "pages.xray.outbound.tag"}}', align: 'center', scopedSlots: { customRender: 'tag' } },
{ title: '{{ i18n "pages.inbounds.traffic" }}', align: 'center', scopedSlots: { customRender: 'traffic' } },
{ title: '{{ i18n "pages.outbounds.test" }}', align: 'center', scopedSlots: { customRender: 'test' } },
{ title: '{{ i18n "pages.inbounds.info" }}', align: 'center', width: 10, scopedSlots: { customRender: 'info' } },
];
const app = new Vue({
@ -245,6 +332,8 @@
refreshInterval: Number(localStorage.getItem("refreshInterval")) || 5000,
showAlert: false,
pageSize: 0,
testResults: {},
testingAll: false,
},
methods: {
loading(spinning = true) { this.spinning = spinning; },
@ -394,6 +483,9 @@
},
generalActions({ key }) {
switch (key) {
case 'testAll':
this.testAllOutbounds();
break;
case 'resetTraffics':
this.resetAllTraffics();
break;
@ -402,6 +494,24 @@
break;
}
},
async testOutbound(id) {
this.$set(this.testResults, id, { testing: true });
const msg = await HttpUtil.post('/xui/outbound/test', { id });
if (!msg.success || !msg.obj) {
this.$set(this.testResults, id, { testing: false, success: false, message: msg.msg });
return;
}
const r = msg.obj;
this.$set(this.testResults, r.id, { testing: false, success: r.success, delay: r.delay, message: r.message });
},
async testAllOutbounds() {
if (this.testingAll) {
return;
}
this.testingAll = true;
await Promise.all(this.dbOutbounds.map(o => this.testOutbound(o.id)));
this.testingAll = false;
},
async submit(url, data, modal) {
const msg = await HttpUtil.postWithModal(url, data, modal);
if (msg.success) {

View file

@ -143,6 +143,9 @@
<setting-list-item type="text" title='{{ i18n "pages.settings.timeZone"}}'
desc='{{ i18n "pages.settings.timeZoneDesc"}}'
v-model="allSetting.timeLocation"></setting-list-item>
<setting-list-item type="text" title='{{ i18n "pages.settings.outboundTestUrl"}}'
desc='{{ i18n "pages.settings.outboundTestUrlDesc"}}'
v-model="allSetting.outboundTestUrl"></setting-list-item>
<a-list-item>
<a-row style="padding: 20px">
<a-col :lg="24" :xl="12">

View file

@ -0,0 +1,171 @@
package service
import (
"crypto/tls"
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/alireza0/x-ui/config"
"github.com/alireza0/x-ui/database/model"
"github.com/alireza0/x-ui/logger"
"github.com/alireza0/x-ui/util/common"
"github.com/alireza0/x-ui/xray"
)
// OutboundTestResult holds the result of testing a single outbound.
type OutboundTestResult struct {
Id int `json:"id"`
Tag string `json:"tag"`
Success bool `json:"success"`
Delay int64 `json:"delay"`
Message string `json:"message"`
}
func (s *OutboundService) TestOutbound(id int) (*OutboundTestResult, error) {
outbound, err := s.GetOutbound(id)
if err != nil {
return nil, err
}
testURL, err := s.settingService.GetOutboundTestUrl()
if err != nil || testURL == "" {
testURL = "https://www.gstatic.com/generate_204"
}
return s.testSingleOutbound(outbound, testURL), nil
}
func (s *OutboundService) testSingleOutbound(outbound *model.Outbound, testURL string) *OutboundTestResult {
result := &OutboundTestResult{
Id: outbound.Id,
Tag: outbound.Tag,
}
port, err := getFreePort()
if err != nil {
result.Message = err.Error()
return result
}
outboundConfig := outbound.GenXrayOutboundConfig()
outboundConfig.Tag = "proxy"
outboundJson, err := json.Marshal(outboundConfig)
if err != nil {
result.Message = err.Error()
return result
}
configContent := config.GetTestXrayTemplate()
configContent = strings.Replace(configContent, "__INBOUND_PORT__", strconv.Itoa(port), 1)
configContent = strings.Replace(configContent, "__OUTBOUND__", string(outboundJson), 1)
binFolder := config.GetBinFolderPath()
configPath := filepath.Join(binFolder, fmt.Sprintf("test_outbound_%d.json", port))
if err := os.WriteFile(configPath, []byte(configContent), 0o644); err != nil {
result.Message = err.Error()
return result
}
defer os.Remove(configPath)
absConfigPath, err := filepath.Abs(configPath)
if err != nil {
absConfigPath = configPath
}
absBinFolder, err := filepath.Abs(binFolder)
if err != nil {
absBinFolder = binFolder
}
binaryPath, err := filepath.Abs(xray.GetBinaryPath())
if err != nil {
binaryPath = xray.GetBinaryPath()
}
cmd := exec.Command(binaryPath, "-c", absConfigPath)
cmd.Dir = absBinFolder
cmd.Env = append(os.Environ(), "XRAY_LOCATION_ASSET="+absBinFolder)
if err := cmd.Start(); err != nil {
result.Message = err.Error()
return result
}
defer func() {
if cmd.Process != nil {
_ = cmd.Process.Kill()
_, _ = cmd.Process.Wait()
}
}()
if err := waitForPort(port, 5*time.Second); err != nil {
result.Message = "xray failed to start: " + err.Error()
return result
}
delay, err := measureDelay(port, testURL)
if err != nil {
result.Message = err.Error()
return result
}
result.Success = true
result.Delay = delay
logger.Debug("Outbound test succeeded:", outbound.Tag, delay, "ms")
return result
}
func getFreePort() (int, error) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return 0, common.NewErrorf("unable to find a free port: %v", err)
}
defer listener.Close()
return listener.Addr().(*net.TCPAddr).Port, nil
}
func waitForPort(port int, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
addr := fmt.Sprintf("127.0.0.1:%d", port)
for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", addr, 500*time.Millisecond)
if err == nil {
conn.Close()
return nil
}
time.Sleep(100 * time.Millisecond)
}
return common.NewError("timed out waiting for proxy port")
}
func measureDelay(port int, testURL string) (int64, error) {
proxyURL, err := url.Parse(fmt.Sprintf("http://127.0.0.1:%d", port))
if err != nil {
return 0, err
}
transport := &http.Transport{
Proxy: http.ProxyURL(proxyURL),
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client := &http.Client{
Transport: transport,
Timeout: 10 * time.Second,
}
defer transport.CloseIdleConnections()
start := time.Now()
resp, err := client.Get(testURL)
if err != nil {
return 0, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return 0, common.NewErrorf("unexpected status code: %d", resp.StatusCode)
}
return time.Since(start).Milliseconds(), nil
}

View file

@ -33,6 +33,7 @@ var defaultValueMap = map[string]string{
"expireDiff": "0",
"trafficDiff": "0",
"remarkModel": "-ieo",
"outboundTestUrl": "https://www.gstatic.com/generate_204",
"timeLocation": "Asia/Tehran",
"tgBotEnable": "false",
"tgBotToken": "",
@ -312,6 +313,10 @@ func (s *SettingService) GetRemarkModel() (string, error) {
return s.getString("remarkModel")
}
func (s *SettingService) GetOutboundTestUrl() (string, error) {
return s.getString("outboundTestUrl")
}
func (s *SettingService) GetSecret() ([]byte, error) {
secret, err := s.getString("secret")
if secret == defaultValueMap["secret"] {

View file

@ -208,6 +208,12 @@
"deleteConfirm" = "Are you sure you want to delete this outbound?"
"resetTraffic" = "Reset Traffic"
"resetAllTraffic" = "Reset All Outbound Traffic"
"test" = "Test Outbound"
"testAll" = "Test All Outbounds"
"testTooltip" = "Test connection and latency"
"testing" = "Testing..."
"testSuccess" = "Reachable"
"testFailed" = "Unreachable"
[pages.outbounds.toasts]
"obtain" = "Failed to load outbounds"
@ -529,6 +535,8 @@
"tgNotifyCpuDesc" = "Get notified if CPU load exceeds the set threshold. (Unit: %)"
"timeZone" = "Time Zone"
"timeZoneDesc" = "Scheduled tasks will run based on this time zone."
"outboundTestUrl" = "Outbound Test URL"
"outboundTestUrlDesc" = "URL used to test outbound connectivity and latency. A lightweight endpoint that returns 204 is recommended."
"subSettings" = "Subscription"
"subEnable" = "Enable Subscription Service"
"subEnableDesc" = "Enables the subscription service."

View file

@ -208,6 +208,12 @@
"deleteConfirm" = "آیا از حذف این خروجی مطمئن هستید؟"
"resetTraffic" = "ریست ترافیک"
"resetAllTraffic" = "ریست ترافیک همه خروجی‌ها"
"test" = "تست خروجی"
"testAll" = "تست همه خروجی‌ها"
"testTooltip" = "تست اتصال و تأخیر"
"testing" = "در حال تست..."
"testSuccess" = "در دسترس"
"testFailed" = "در دسترس نیست"
[pages.outbounds.toasts]
"obtain" = "دریافت خروجی‌ها ناموفق بود"
@ -529,6 +535,8 @@
"tgNotifyCpuDesc" = "اگر بار پردازنده از آستانه تعیین‌شده فراتر رفت، مطلع می‌شوید. واحد: درصد"
"timeZone" = "منطقه زمانی"
"timeZoneDesc" = "وظایف برنامه ریزی شده بر اساس این منطقه‌زمانی اجرا می‌شود"
"outboundTestUrl" = "آدرس تست خروجی"
"outboundTestUrlDesc" = "آدرسی که برای تست اتصال و تأخیر خروجی‌ها استفاده می‌شود. یک نقطهٔ سبک که کد ۲۰۴ برمی‌گرداند توصیه می‌شود."
"subSettings" = "سابسکریپشن"
"subEnable" = "فعال‌سازی سرویس سابسکریپشن"
"subEnableDesc" = " سرویس سابسکریپشن‌ را فعال می‌کند"

View file

@ -208,6 +208,12 @@
"deleteConfirm" = "Удалить этот исходящий?"
"resetTraffic" = "Сбросить трафик"
"resetAllTraffic" = "Сбросить весь трафик исходящих"
"test" = "Тест исходящего"
"testAll" = "Тест всех исходящих"
"testTooltip" = "Проверка соединения и задержки"
"testing" = "Тестирование..."
"testSuccess" = "Доступен"
"testFailed" = "Недоступен"
[pages.outbounds.toasts]
"obtain" = "Не удалось загрузить исходящие"
@ -529,6 +535,8 @@
"tgNotifyCpuDesc" = "Получение уведомления, если нагрузка на ЦП превышает этот порог (единица измерения:%)"
"timeZone" = "Часовой пояс"
"timeZoneDesc" = "Запланированные задания выполняются в соответствии со временем в данном часовом поясе."
"outboundTestUrl" = "URL для теста исходящих"
"outboundTestUrlDesc" = "URL для проверки соединения и задержки исходящих. Рекомендуется лёгкий эндпоинт, возвращающий 204."
"subSettings" = "Подписка"
"subEnable" = "Включить службу"
"subEnableDesc" = "Функция подписки с отдельной конфигурацией"

View file

@ -208,6 +208,12 @@
"deleteConfirm" = "Bạn có chắc muốn xóa outbound này?"
"resetTraffic" = "Đặt lại lưu lượng"
"resetAllTraffic" = "Đặt lại tất cả lưu lượng outbound"
"test" = "Kiểm tra outbound"
"testAll" = "Kiểm tra tất cả outbound"
"testTooltip" = "Kiểm tra kết nối và độ trễ"
"testing" = "Đang kiểm tra..."
"testSuccess" = "Có thể kết nối"
"testFailed" = "Không thể kết nối"
[pages.outbounds.toasts]
"obtain" = "Không thể tải outbounds"
@ -529,6 +535,8 @@
"tgNotifyCpuDesc" = "Nhận thông báo nếu tỷ lệ sử dụng CPU vượt quá ngưỡng này (đơn vị: %)"
"timeZone" = "Múi giờ"
"timeZoneDesc" = "Các tác vụ được lên lịch chạy theo thời gian trong múi giờ này."
"outboundTestUrl" = "URL kiểm tra outbound"
"outboundTestUrlDesc" = "URL dùng để kiểm tra kết nối và độ trễ outbound. Nên dùng endpoint nhẹ trả về 204."
"subSettings" = "Đăng ký"
"subEnable" = "Bật dịch vụ"
"subEnableDesc" = "Tính năng đăng ký với cấu hình riêng"

View file

@ -208,6 +208,12 @@
"deleteConfirm" = "确定要删除此出站吗?"
"resetTraffic" = "重置流量"
"resetAllTraffic" = "重置所有出站流量"
"test" = "测试出站"
"testAll" = "测试所有出站"
"testTooltip" = "测试连接和延迟"
"testing" = "测试中..."
"testSuccess" = "可达"
"testFailed" = "不可达"
[pages.outbounds.toasts]
"obtain" = "获取出站失败"
@ -529,6 +535,8 @@
"tgNotifyCpuDesc" = "如果 CPU 使用率超过此百分比(单位:%),此 talegram bot 将向您发送通知"
"timeZone" = "时区"
"timeZoneDesc" = "定时任务按照该时区的时间运行"
"outboundTestUrl" = "出站测试网址"
"outboundTestUrlDesc" = "用于测试出站连接和延迟的网址。建议使用返回 204 的轻量级端点。"
"subSettings" = "订阅"
"subEnable" = "启用服务"
"subEnableDesc" = "具有单独配置的订阅功能"