From 9f841662f178812dc2cba7f1d373d72658ee12e0 Mon Sep 17 00:00:00 2001 From: Surbiks Date: Sat, 7 Feb 2026 23:56:05 +0330 Subject: [PATCH] add outbound testing functionality with configurable test URL --- web/controller/xray_setting.go | 46 ++++- web/html/settings/xray/basics.html | 7 + web/html/settings/xray/outbounds.html | 30 ++++ web/html/xray.html | 78 ++++++++- web/job/check_client_ip_job.go | 5 +- web/service/outbound.go | 239 ++++++++++++++++++++++++++ web/service/setting.go | 9 + web/translation/translate.ar_EG.toml | 2 + web/translation/translate.en_US.toml | 10 ++ web/translation/translate.es_ES.toml | 2 + web/translation/translate.fa_IR.toml | 4 + web/translation/translate.id_ID.toml | 2 + web/translation/translate.ja_JP.toml | 2 + web/translation/translate.pt_BR.toml | 2 + web/translation/translate.ru_RU.toml | 4 + web/translation/translate.tr_TR.toml | 2 + web/translation/translate.uk_UA.toml | 2 + web/translation/translate.vi_VN.toml | 2 + web/translation/translate.zh_CN.toml | 4 + web/translation/translate.zh_TW.toml | 2 + xray/process.go | 38 +++- 21 files changed, 478 insertions(+), 14 deletions(-) diff --git a/web/controller/xray_setting.go b/web/controller/xray_setting.go index b78925f0..2af08094 100644 --- a/web/controller/xray_setting.go +++ b/web/controller/xray_setting.go @@ -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 = "http://www.google.com/gen_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 = "http://www.google.com/gen_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) +} diff --git a/web/html/settings/xray/basics.html b/web/html/settings/xray/basics.html index 71aa0d7c..728c8e4d 100644 --- a/web/html/settings/xray/basics.html +++ b/web/html/settings/xray/basics.html @@ -33,6 +33,13 @@ + + + + + diff --git a/web/html/settings/xray/outbounds.html b/web/html/settings/xray/outbounds.html index 1c099308..fdc2ae4a 100644 --- a/web/html/settings/xray/outbounds.html +++ b/web/html/settings/xray/outbounds.html @@ -71,6 +71,36 @@ + + {{end}} \ No newline at end of file diff --git a/web/html/xray.html b/web/html/xray.html index 186156ff..7ca6885c 100644 --- a/web/html/xray.html +++ b/web/html/xray.html @@ -181,11 +181,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 +230,11 @@ }, oldXraySetting: '', xraySetting: '', + outboundTestUrl: 'http://www.google.com/gen_204', + oldOutboundTestUrl: 'http://www.google.com/gen_204', inboundTags: [], outboundsTraffic: [], + outboundTestStates: {}, // Track testing state and results for each outbound saveBtnDisable: true, refreshing: false, restartResult: '', @@ -375,12 +380,17 @@ this.oldXraySetting = xs; this.xraySetting = xs; this.inboundTags = result.inboundTags; + this.outboundTestUrl = result.outboundTestUrl || 'http://www.google.com/gen_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 || 'http://www.google.com/gen_204' + }); this.loading(false); if (msg.success) { await this.getXraySetting(); @@ -595,6 +605,68 @@ 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; + } + + // 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 || 'http://www.google.com/gen_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 (HTTP ${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 +1053,7 @@ while (true) { await PromiseUtil.sleep(800); - this.saveBtnDisable = this.oldXraySetting === this.xraySetting; + this.saveBtnDisable = this.oldXraySetting === this.xraySetting && this.oldOutboundTestUrl === this.outboundTestUrl; } }, computed: { diff --git a/web/job/check_client_ip_job.go b/web/job/check_client_ip_job.go index 94486236..d3c1a1d1 100644 --- a/web/job/check_client_ip_job.go +++ b/web/job/check_client_ip_job.go @@ -3,7 +3,6 @@ package job import ( "bufio" "encoding/json" - "fmt" "io" "log" "os" @@ -388,7 +387,7 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun // disconnectClientTemporarily removes and re-adds a client to force disconnect old connections func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, clientEmail string, clients []model.Client) { var xrayAPI xray.XrayAPI - + // Get panel settings for API port db := database.GetDB() var apiPort int @@ -396,7 +395,7 @@ func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, c if err := db.Where("key = ?", "xrayApiPort").First(&apiPortSetting).Error; err == nil { apiPort, _ = strconv.Atoi(apiPortSetting.Value) } - + if apiPort == 0 { apiPort = 10085 // Default API port } diff --git a/web/service/outbound.go b/web/service/outbound.go index 530d12eb..7b86debd 100644 --- a/web/service/outbound.go +++ b/web/service/outbound.go @@ -1,9 +1,20 @@ package service import ( + "encoding/json" + "fmt" + "net" + "net/http" + "net/url" + "os" + "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" @@ -100,3 +111,231 @@ 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 = "http://www.google.com/gen_204" + } + + // Parse the outbound being tested to get its tag + var testOutbound map[string]interface{} + 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 + } + + // Use all outbounds when provided; otherwise fall back to single outbound + var allOutbounds []interface{} + 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 = []interface{}{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() + // Give it a moment to clean up + time.Sleep(500 * time.Millisecond) + } + }() + + // 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 a bit for xray to start + time.Sleep(1 * time.Second) + + // 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 []interface{}, 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 as-is, no tag or structure changes + outboundsJSON, _ := json.Marshal(allOutbounds) + + // Create routing rule to route all traffic through test outbound + routingRules := []map[string]interface{}{ + { + "type": "field", + "outboundTag": outboundTag, + "network": "tcp,udp", + }, + } + + routingJSON, _ := json.Marshal(map[string]interface{}{ + "domainStrategy": "AsIs", + "rules": routingRules, + }) + + // Create minimal config + config := &xray.Config{ + LogConfig: json_util.RawMessage(`{ + "loglevel":"info", + "access":"` + config.GetBinFolderPath() + `/access_tests.log", + "error":"` + config.GetBinFolderPath() + `/error_tests.log", + "dnsLog":true + }`), + 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 config +} + +// testConnection tests the connection through the proxy and measures delay +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 + client := &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + Proxy: http.ProxyURL(proxyURLParsed), + DialContext: (&net.Dialer{ + Timeout: 10 * time.Second, + }).DialContext, + }, + } + + // Measure time + 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) + } + defer resp.Body.Close() + + return delay, resp.StatusCode, nil +} + +// 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 file is not created; the path is reserved by creating and then removing an empty temp file. +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 + } + if err := os.Remove(path); err != nil { + return "", err + } + return path, nil +} diff --git a/web/service/setting.go b/web/service/setting.go index 3fa37f44..e2feb3a7 100644 --- a/web/service/setting.go +++ b/web/service/setting.go @@ -99,6 +99,7 @@ var defaultValueMap = map[string]string{ "ldapDefaultTotalGB": "0", "ldapDefaultExpiryDays": "0", "ldapDefaultLimitIP": "0", + "xrayOutboundTestUrl": "http://www.google.com/gen_204", } // SettingService provides business logic for application settings management. @@ -271,6 +272,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") } diff --git a/web/translation/translate.ar_EG.toml b/web/translation/translate.ar_EG.toml index 6d75d196..54943fa9 100644 --- a/web/translation/translate.ar_EG.toml +++ b/web/translation/translate.ar_EG.toml @@ -460,6 +460,8 @@ "FreedomStrategyDesc" = "اختار استراتيجية المخرجات للشبكة في بروتوكول الحرية." "RoutingStrategy" = "استراتيجية التوجيه العامة" "RoutingStrategyDesc" = "حدد استراتيجية التوجيه الإجمالية لحل كل الطلبات." +"outboundTestUrl" = "رابط اختبار المخرج" +"outboundTestUrlDesc" = "الرابط المستخدم عند اختبار اتصال المخرج" "Torrent" = "حظر بروتوكول التورنت" "Inbounds" = "الإدخالات" "InboundsDesc" = "قبول العملاء المعينين." diff --git a/web/translation/translate.en_US.toml b/web/translation/translate.en_US.toml index 244e6f2c..6edcf035 100644 --- a/web/translation/translate.en_US.toml +++ b/web/translation/translate.en_US.toml @@ -427,6 +427,8 @@ "information" = "Information" "language" = "Language" "telegramBotLanguage" = "Telegram Bot Language" +"outboundTestURL" = "Outbound Test URL" +"outboundTestURLDesc" = "URL used to test outbound connection" [pages.xray] "title" = "Xray Configs" @@ -460,6 +462,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. Default is http://www.google.com/gen_204" "Torrent" = "Block BitTorrent Protocol" "Inbounds" = "Inbounds" "InboundsDesc" = "Accepting the specific clients." @@ -523,6 +527,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" diff --git a/web/translation/translate.es_ES.toml b/web/translation/translate.es_ES.toml index b0dde898..7bc6722e 100644 --- a/web/translation/translate.es_ES.toml +++ b/web/translation/translate.es_ES.toml @@ -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." diff --git a/web/translation/translate.fa_IR.toml b/web/translation/translate.fa_IR.toml index 1eda5fb5..04f8bead 100644 --- a/web/translation/translate.fa_IR.toml +++ b/web/translation/translate.fa_IR.toml @@ -427,6 +427,8 @@ "information" = "اطلاعات" "language" = "زبان" "telegramBotLanguage" = "زبان ربات تلگرام" +"outboundTestURL" = "آدرس تست خروجی" +"outboundTestURLDesc" = "آدرسی که برای تست اتصال خروجی استفاده می‌شود" [pages.xray] "title" = "پیکربندی ایکس‌ری" @@ -460,6 +462,8 @@ "FreedomStrategyDesc" = "تعیین می‌کند Freedom استراتژی خروجی شبکه را برای پروتکل" "RoutingStrategy" = "استراتژی کلی مسیریابی" "RoutingStrategyDesc" = "استراتژی کلی مسیریابی برای حل تمام درخواست‌ها را تعیین می‌کند" +"outboundTestUrl" = "آدرس تست خروجی" +"outboundTestUrlDesc" = "آدرسی که برای تست اتصال خروجی استفاده می‌شود. پیش‌فرض: http://www.google.com/gen_204" "Torrent" = "مسدودسازی پروتکل بیت‌تورنت" "Inbounds" = "ورودی‌ها" "InboundsDesc" = "پذیرش کلاینت خاص" diff --git a/web/translation/translate.id_ID.toml b/web/translation/translate.id_ID.toml index 8804ef04..3034d680 100644 --- a/web/translation/translate.id_ID.toml +++ b/web/translation/translate.id_ID.toml @@ -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." diff --git a/web/translation/translate.ja_JP.toml b/web/translation/translate.ja_JP.toml index 8bba24c0..f72372ad 100644 --- a/web/translation/translate.ja_JP.toml +++ b/web/translation/translate.ja_JP.toml @@ -460,6 +460,8 @@ "FreedomStrategyDesc" = "Freedomプロトコル内のネットワークの出力戦略を設定する" "RoutingStrategy" = "ルーティングドメイン戦略設定" "RoutingStrategyDesc" = "DNS解決の全体的なルーティング戦略を設定する" +"outboundTestUrl" = "アウトバウンドテスト URL" +"outboundTestUrlDesc" = "アウトバウンド接続テストに使用する URL。既定値" "Torrent" = "BitTorrent プロトコルをブロック" "Inbounds" = "インバウンドルール" "InboundsDesc" = "特定のクライアントからのトラフィックを受け入れる" diff --git a/web/translation/translate.pt_BR.toml b/web/translation/translate.pt_BR.toml index 1b173e6e..8af0d2e8 100644 --- a/web/translation/translate.pt_BR.toml +++ b/web/translation/translate.pt_BR.toml @@ -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." diff --git a/web/translation/translate.ru_RU.toml b/web/translation/translate.ru_RU.toml index 895734f5..3142fb56 100644 --- a/web/translation/translate.ru_RU.toml +++ b/web/translation/translate.ru_RU.toml @@ -427,6 +427,8 @@ "information" = "Информация" "language" = "Язык интерфейса" "telegramBotLanguage" = "Язык Telegram-бота" +"outboundTestURL" = "URL для тестирования исходящего подключения" +"outboundTestURLDesc" = "URL, используемый для тестирования исходящего подключения" [pages.xray] "title" = "Настройки Xray" @@ -460,6 +462,8 @@ "FreedomStrategyDesc" = "Установка стратегии вывода сети в протоколе Freedom" "RoutingStrategy" = "Настройка маршрутизации доменов" "RoutingStrategyDesc" = "Установка общей стратегии маршрутизации разрешения DNS" +"outboundTestUrl" = "URL для теста исходящего" +"outboundTestUrlDesc" = "URL для проверки подключения исходящего. По умолчанию: http://www.google.com/gen_204" "Torrent" = "Заблокировать BitTorrent" "Inbounds" = "Входящие подключения" "InboundsDesc" = "Изменение шаблона конфигурации для подключения определенных клиентов" diff --git a/web/translation/translate.tr_TR.toml b/web/translation/translate.tr_TR.toml index 50639358..c121b999 100644 --- a/web/translation/translate.tr_TR.toml +++ b/web/translation/translate.tr_TR.toml @@ -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." diff --git a/web/translation/translate.uk_UA.toml b/web/translation/translate.uk_UA.toml index 54d45889..74b11531 100644 --- a/web/translation/translate.uk_UA.toml +++ b/web/translation/translate.uk_UA.toml @@ -460,6 +460,8 @@ "FreedomStrategyDesc" = "Установити стратегію виведення для мережі в протоколі свободи." "RoutingStrategy" = "Загальна стратегія маршрутизації" "RoutingStrategyDesc" = "Установити загальну стратегію маршрутизації трафіку для вирішення всіх запитів." +"outboundTestUrl" = "URL тесту outbound" +"outboundTestUrlDesc" = "URL для перевірки з'єднання outbound" "Torrent" = "Блокувати протокол BitTorrent" "Inbounds" = "Вхідні" "InboundsDesc" = "Прийняття певних клієнтів." diff --git a/web/translation/translate.vi_VN.toml b/web/translation/translate.vi_VN.toml index 3fa63bb1..2b35d44e 100644 --- a/web/translation/translate.vi_VN.toml +++ b/web/translation/translate.vi_VN.toml @@ -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ể." diff --git a/web/translation/translate.zh_CN.toml b/web/translation/translate.zh_CN.toml index d6b82b93..37d33331 100644 --- a/web/translation/translate.zh_CN.toml +++ b/web/translation/translate.zh_CN.toml @@ -427,6 +427,8 @@ "information" = "信息" "language" = "语言" "telegramBotLanguage" = "Telegram 机器人语言" +"outboundTestURL" = "出站测试 URL" +"outboundTestURLDesc" = "用于测试出站连接的 URL" [pages.xray] "title" = "Xray 配置" @@ -460,6 +462,8 @@ "FreedomStrategyDesc" = "设置 Freedom 协议中网络的输出策略" "RoutingStrategy" = "配置路由域策略" "RoutingStrategyDesc" = "设置 DNS 解析的整体路由策略" +"outboundTestUrl" = "出站测试 URL" +"outboundTestUrlDesc" = "测试出站连接时使用的 URL,默认为 http://www.google.com/gen_204" "Torrent" = "屏蔽 BitTorrent 协议" "Inbounds" = "入站规则" "InboundsDesc" = "接受来自特定客户端的流量" diff --git a/web/translation/translate.zh_TW.toml b/web/translation/translate.zh_TW.toml index 616f2322..288b89c5 100644 --- a/web/translation/translate.zh_TW.toml +++ b/web/translation/translate.zh_TW.toml @@ -460,6 +460,8 @@ "FreedomStrategyDesc" = "設定 Freedom 協議中網路的輸出策略" "RoutingStrategy" = "配置路由域策略" "RoutingStrategyDesc" = "設定 DNS 解析的整體路由策略" +"outboundTestUrl" = "出站測試 URL" +"outboundTestUrlDesc" = "測試出站連線時使用的 URL" "Torrent" = "遮蔽 BitTorrent 協議" "Inbounds" = "入站規則" "InboundsDesc" = "接受來自特定客戶端的流量" diff --git a/xray/process.go b/xray/process.go index f45d6cc9..009ec7a5 100644 --- a/xray/process.go +++ b/xray/process.go @@ -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 {