From 5716ae5987ae38b6fba8a78f3cd61ad1a5383305 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Fri, 12 Jun 2026 16:55:53 +0200 Subject: [PATCH] feat(outbound): batched connection tester with direct timed HTTP probes Replace the per-outbound burstObservatory polling (one temp xray spawn + up to 15s of /debug/vars polling per outbound, serialised) with one shared temp xray instance per batch: every tested outbound gets its own loopback SOCKS inbound plus an inboundTag->outboundTag routing rule, and the panel times a real HTTP request through each one in parallel. The probe returns as soon as the response lands and records the HTTP status plus an httptrace breakdown (proxy connect / TLS via outbound / first byte) shown in the result popover. New POST /panel/api/xray/testOutbounds endpoint (array in, results in input order, max 50); the legacy /testOutbound endpoint now delegates to the same engine. Test All chunks HTTP probes 16 per request, and a batch whose shared process never comes up (one structurally-broken outbound poisons the config) retries each item in an isolated instance so the broken outbound reports xray's real error while the rest still test. --- frontend/public/openapi.json | 40 ++ frontend/src/hooks/useXraySetting.ts | 71 ++- frontend/src/pages/api-docs/endpoints.ts | 11 + .../pages/xray/outbounds/OutboundCardList.tsx | 8 +- .../src/pages/xray/outbounds/OutboundsTab.css | 19 + .../xray/outbounds/SubscriptionOutbounds.tsx | 42 +- .../xray/outbounds/TestResultPopover.tsx | 68 +++ .../xray/outbounds/outbounds-tab-helpers.ts | 6 - .../xray/outbounds/useOutboundColumns.tsx | 37 +- frontend/src/schemas/xray.ts | 11 + internal/web/controller/xray_setting.go | 34 ++ internal/web/service/outbound/outbound.go | 328 +---------- internal/web/service/outbound/probe_http.go | 552 ++++++++++++++++++ .../web/service/outbound/probe_http_test.go | 470 +++++++++++++++ internal/web/translation/ar-EG.json | 4 + internal/web/translation/en-US.json | 4 + internal/web/translation/es-ES.json | 4 + internal/web/translation/fa-IR.json | 4 + internal/web/translation/id-ID.json | 4 + internal/web/translation/ja-JP.json | 4 + internal/web/translation/pt-BR.json | 4 + internal/web/translation/ru-RU.json | 4 + internal/web/translation/tr-TR.json | 4 + internal/web/translation/uk-UA.json | 4 + internal/web/translation/vi-VN.json | 4 + internal/web/translation/zh-CN.json | 4 + internal/web/translation/zh-TW.json | 4 + 27 files changed, 1333 insertions(+), 416 deletions(-) create mode 100644 frontend/src/pages/xray/outbounds/TestResultPopover.tsx create mode 100644 internal/web/service/outbound/probe_http.go create mode 100644 internal/web/service/outbound/probe_http_test.go diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index c25ce6ee9..2d72a3028 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -7524,6 +7524,46 @@ } } }, + "/panel/api/xray/testOutbounds": { + "post": { + "tags": [ + "Xray Settings" + ], + "summary": "Test a batch of outbounds (max 50) through one shared temp xray instance. Returns an array of results in input order, each with the outbound tag, delay, HTTP status and a connect/TLS/TTFB timing breakdown.", + "operationId": "post_panel_api_xray_testOutbounds", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": {} + } + } + } + } + } + } + } + }, "/panel/api/xray/balancerStatus": { "post": { "tags": [ diff --git a/frontend/src/hooks/useXraySetting.ts b/frontend/src/hooks/useXraySetting.ts index dd025a79d..8a024d5aa 100644 --- a/frontend/src/hooks/useXraySetting.ts +++ b/frontend/src/hooks/useXraySetting.ts @@ -7,7 +7,7 @@ import { parseMsg } from '@/utils/zodValidate'; import { keys } from '@/api/queryKeys'; import { OutboundTrafficListSchema, - OutboundTestResultSchema, + OutboundTestResultListSchema, XrayConfigPayloadSchema, XraySettingsValueSchema, type OutboundTestResult, @@ -16,6 +16,10 @@ import { const DIRTY_POLL_MS = 1000; const DEFAULT_TEST_URL = 'https://www.google.com/generate_204'; +// One HTTP-mode batch request tests this many outbounds through a single +// shared temp xray instance; chunking keeps responses bounded (~15s worst +// case) and lands Test All results progressively. +const HTTP_BATCH_CHUNK = 16; export function isUdpOutbound(outbound: unknown): boolean { const o = outbound as { protocol?: string; streamSettings?: { network?: string } } | null | undefined; @@ -254,21 +258,26 @@ export function useXraySetting(): UseXraySettingResult { const spinning = saveMut.isPending || resetDefaultMut.isPending; - // Shared POST + parse for a single outbound test. Returns an OutboundTestResult - // (success or a failure-shaped result); callers store it under their own key. - const postOutboundTest = useCallback( - async (outbound: unknown, effMode: string): Promise => { + // Shared POST + parse for a batch of outbound tests. The backend probes the + // whole batch through one shared temp xray instance and returns results in + // request order; this aligns them by index and shapes failures so every + // input gets an OutboundTestResult. + const postOutboundTestBatch = useCallback( + async (outbounds: unknown[], effMode: string): Promise => { + const failAll = (error: string): OutboundTestResult[] => + outbounds.map(() => ({ success: false, error, mode: effMode })); try { - const raw = await HttpUtil.post('/panel/api/xray/testOutbound', { - outbound: JSON.stringify(outbound), + const raw = await HttpUtil.post('/panel/api/xray/testOutbounds', { + outbounds: JSON.stringify(outbounds), allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []), mode: effMode, }); - const msg = parseMsg(raw, OutboundTestResultSchema, 'xray/testOutbound'); - if (msg?.success && msg.obj) return msg.obj; - return { success: false, error: msg?.msg || 'Unknown error', mode: effMode }; + const msg = parseMsg(raw, OutboundTestResultListSchema, 'xray/testOutbounds'); + if (!msg?.success || !Array.isArray(msg.obj)) return failAll(msg?.msg || 'Unknown error'); + const list = msg.obj; + return outbounds.map((_ob, i) => list[i] ?? { success: false, error: 'Missing result', mode: effMode }); } catch (e) { - return { success: false, error: String(e), mode: effMode }; + return failAll(String(e)); } }, [], @@ -282,11 +291,11 @@ export function useXraySetting(): UseXraySettingResult { ...prev, [index]: { testing: true, result: null, mode: effMode }, })); - const result = await postOutboundTest(outbound, effMode); + const [result] = await postOutboundTestBatch([outbound], effMode); setOutboundTestStates((prev) => ({ ...prev, [index]: { testing: false, result } })); return result.success ? result : null; }, - [postOutboundTest], + [postOutboundTestBatch], ); // Test a subscription outbound (not present in templateSettings.outbounds); @@ -299,11 +308,11 @@ export function useXraySetting(): UseXraySettingResult { ...prev, [tag]: { testing: true, result: null, mode: effMode }, })); - const result = await postOutboundTest(outbound, effMode); + const [result] = await postOutboundTestBatch([outbound], effMode); setSubscriptionTestStates((prev) => ({ ...prev, [tag]: { testing: false, result } })); return result.success ? result : null; }, - [postOutboundTest], + [postOutboundTestBatch], ); const testAllOutbounds = useCallback(async (mode = 'tcp') => { @@ -324,7 +333,10 @@ export function useXraySetting(): UseXraySettingResult { tcpQueue.push({ index: i, outbound: ob }); } }); - const runLane = async (queue: { index: number; outbound: unknown }[], concurrency: number) => { + // TCP probes are dial-only and cheap server-side; per-item requests + // keep results landing one by one. + const runTcpLane = async () => { + const queue = [...tcpQueue]; const worker = async () => { while (queue.length > 0) { const item = queue.shift(); @@ -332,14 +344,33 @@ export function useXraySetting(): UseXraySettingResult { await testOutbound(item.index, item.outbound, mode); } }; - const workers = Array.from({ length: Math.min(concurrency, queue.length) }, () => worker()); - await Promise.all(workers); + await Promise.all(Array.from({ length: Math.min(8, queue.length) }, () => worker())); }; - await Promise.all([runLane(tcpQueue, 8), runLane(httpQueue, 1)]); + // HTTP probes go out as chunked batches — one temp xray spawn per + // chunk instead of one per outbound, with results landing per chunk. + const runHttpLane = async () => { + for (let at = 0; at < httpQueue.length; at += HTTP_BATCH_CHUNK) { + const chunk = httpQueue.slice(at, at + HTTP_BATCH_CHUNK); + setOutboundTestStates((prev) => { + const next = { ...prev }; + for (const item of chunk) next[item.index] = { testing: true, result: null, mode: 'http' }; + return next; + }); + const results = await postOutboundTestBatch(chunk.map((c) => c.outbound), 'http'); + setOutboundTestStates((prev) => { + const next = { ...prev }; + chunk.forEach((item, i) => { + next[item.index] = { testing: false, result: results[i] }; + }); + return next; + }); + } + }; + await Promise.all([runTcpLane(), runHttpLane()]); } finally { setTestingAll(false); } - }, [testingAll, testOutbound]); + }, [testingAll, testOutbound, postOutboundTestBatch]); useEffect(() => { const timer = window.setInterval(() => { diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index e9aa879e1..e7837bbb0 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -1063,6 +1063,17 @@ export const sections: readonly Section[] = [ ], body: 'outbound={"protocol":"freedom","settings":{}}&mode=tcp', }, + { + method: 'POST', + path: '/panel/api/xray/testOutbounds', + summary: 'Test a batch of outbounds (max 50) through one shared temp xray instance. Returns an array of results in input order, each with the outbound tag, delay, HTTP status and a connect/TLS/TTFB timing breakdown.', + params: [ + { name: 'outbounds', in: 'body (form)', type: 'string', desc: 'JSON array of outbound configs to test (required).' }, + { name: 'allOutbounds', in: 'body (form)', type: 'string', desc: 'JSON array of all outbounds — used to resolve dialerProxy chains.' }, + { name: 'mode', in: 'body (form)', type: 'string', desc: '"tcp" for fast dial-only probes (UDP-transport outbounds are still probed over HTTP). Default/empty routes a real HTTP request through each outbound.' }, + ], + body: 'outbounds=[{"tag":"direct","protocol":"freedom","settings":{}}]&mode=http', + }, { method: 'POST', path: '/panel/api/xray/balancerStatus', diff --git a/frontend/src/pages/xray/outbounds/OutboundCardList.tsx b/frontend/src/pages/xray/outbounds/OutboundCardList.tsx index e438dbbbe..0fa65ac3e 100644 --- a/frontend/src/pages/xray/outbounds/OutboundCardList.tsx +++ b/frontend/src/pages/xray/outbounds/OutboundCardList.tsx @@ -7,8 +7,6 @@ import { DeleteOutlined, VerticalAlignTopOutlined, ThunderboltOutlined, - CheckCircleFilled, - CloseCircleFilled, LoadingOutlined, } from '@ant-design/icons'; @@ -17,6 +15,7 @@ import { OutboundProtocols as Protocols } from '@/schemas/primitives'; import type { OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting'; import type { OutboundRow } from './outbounds-tab-types'; +import TestResultPopover from './TestResultPopover'; import { isTesting, isUntestable, @@ -102,10 +101,7 @@ export default function OutboundCardList({ ↓ {SizeFormatter.sizeFormat(trafficFor(outboundsTraffic, record).down)} {testResult(outboundTestStates, index) ? ( - - {testResult(outboundTestStates, index)!.success ? : } - {testResult(outboundTestStates, index)!.success ? {testResult(outboundTestStates, index)!.delay} ms : failed} - + ) : isTesting(outboundTestStates, index) ? ( ) : null} diff --git a/frontend/src/pages/xray/outbounds/OutboundsTab.css b/frontend/src/pages/xray/outbounds/OutboundsTab.css index 6bc9a0084..93145ccdd 100644 --- a/frontend/src/pages/xray/outbounds/OutboundsTab.css +++ b/frontend/src/pages/xray/outbounds/OutboundsTab.css @@ -210,6 +210,25 @@ color: #e04141; } +.outbound-test-popover .breakdown-row { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + white-space: nowrap; +} + +.outbound-test-popover .breakdown-row .bd-label { + flex: 1; + min-width: 0; + opacity: 0.85; +} + +.outbound-test-popover .breakdown-row .bd-value { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + opacity: 0.75; +} + .subscription-outbounds-head { margin-bottom: 8px; } diff --git a/frontend/src/pages/xray/outbounds/SubscriptionOutbounds.tsx b/frontend/src/pages/xray/outbounds/SubscriptionOutbounds.tsx index cbd648a3a..0ef0ba5fe 100644 --- a/frontend/src/pages/xray/outbounds/SubscriptionOutbounds.tsx +++ b/frontend/src/pages/xray/outbounds/SubscriptionOutbounds.tsx @@ -1,12 +1,7 @@ import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, Popover, Table, Tag, Tooltip } from 'antd'; -import { - ThunderboltOutlined, - CheckCircleFilled, - CloseCircleFilled, - LoadingOutlined, -} from '@ant-design/icons'; +import { Button, Table, Tag, Tooltip } from 'antd'; +import { ThunderboltOutlined, LoadingOutlined } from '@ant-design/icons'; import type { ColumnsType } from 'antd/es/table'; import { SizeFormatter } from '@/utils'; @@ -15,8 +10,8 @@ import { isUdpOutbound } from '@/hooks/useXraySetting'; import type { OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting'; import type { OutboundRow } from './outbounds-tab-types'; +import TestResultPopover from './TestResultPopover'; import { - hasBreakdown, isTesting, isUntestable, outboundAddresses, @@ -103,36 +98,7 @@ export default function SubscriptionOutbounds({ const key = record.tag || ''; const r = testResult(subscriptionTestStates, key); if (!r) return isTesting(subscriptionTestStates, key) ? : ; - return ( - -
- {r.success ? {r.delay} ms : {r.error || 'failed'}} - {r.mode && {String(r.mode).toUpperCase()}} -
- {hasBreakdown(r) && ( - <> - {(r.endpoints || []).map((ep) => ( -
- - {ep.address} - {ep.success ? `${ep.delay} ms` : ep.error || 'failed'} -
- ))} - - )} - - } - > - - {r.success ? : } - {r.success ? {r.delay} ms : failed} - -
- ); + return ; }; const testButton = (record: OutboundRow) => { diff --git a/frontend/src/pages/xray/outbounds/TestResultPopover.tsx b/frontend/src/pages/xray/outbounds/TestResultPopover.tsx new file mode 100644 index 000000000..c3b0bd415 --- /dev/null +++ b/frontend/src/pages/xray/outbounds/TestResultPopover.tsx @@ -0,0 +1,68 @@ +import type { ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Popover } from 'antd'; +import { CheckCircleFilled, CloseCircleFilled } from '@ant-design/icons'; + +import type { OutboundTestResult } from '@/hooks/useXraySetting'; + +interface TestResultPopoverProps { + result: OutboundTestResult; + // Custom trigger element; defaults to the ok/fail latency pill. + children?: ReactNode; +} + +// Latency pill + detail popover for an outbound test result: per-endpoint +// dial outcomes for TCP probes, HTTP status and the timing breakdown for +// HTTP probes. +export default function TestResultPopover({ result: r, children }: TestResultPopoverProps) { + const { t } = useTranslation(); + + const breakdown: Array<{ key: string; label: string; value: string }> = []; + if (typeof r.httpStatus === 'number') { + breakdown.push({ key: 'status', label: t('pages.xray.outbound.httpStatus'), value: String(r.httpStatus) }); + } + if (typeof r.connectMs === 'number') { + breakdown.push({ key: 'connect', label: t('pages.xray.outbound.breakdownConnect'), value: `${r.connectMs} ms` }); + } + if (typeof r.tlsMs === 'number') { + breakdown.push({ key: 'tls', label: t('pages.xray.outbound.breakdownTls'), value: `${r.tlsMs} ms` }); + } + if (typeof r.ttfbMs === 'number') { + breakdown.push({ key: 'ttfb', label: t('pages.xray.outbound.breakdownTtfb'), value: `${r.ttfbMs} ms` }); + } + + return ( + +
+ {r.success ? {r.delay} ms : {r.error || 'failed'}} + {r.mode && {String(r.mode).toUpperCase()}} +
+ {(r.endpoints || []).map((ep) => ( +
+ + {ep.address} + {ep.success ? `${ep.delay} ms` : ep.error || 'failed'} +
+ ))} + {breakdown.map((row) => ( +
+ {row.label} + {row.value} +
+ ))} + + } + > + {children ?? ( + + {r.success ? : } + {r.success ? {r.delay} ms : failed} + + )} +
+ ); +} diff --git a/frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts b/frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts index 91f7ff6cb..b09f93dd3 100644 --- a/frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts +++ b/frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts @@ -42,12 +42,6 @@ export function showSecurity(security?: string): boolean { return security === 'tls' || security === 'reality'; } -export function hasBreakdown(r: { endpoints?: unknown[]; error?: string } | null | undefined): boolean { - if (!r) return false; - if (r.endpoints?.length) return true; - return !!r.error; -} - export function trafficFor(outboundsTraffic: OutboundTrafficRow[], o: OutboundRow): { up: number; down: number } { const tr = outboundsTraffic.find((x) => x.tag === o.tag); return { up: tr?.up || 0, down: tr?.down || 0 }; diff --git a/frontend/src/pages/xray/outbounds/useOutboundColumns.tsx b/frontend/src/pages/xray/outbounds/useOutboundColumns.tsx index 0a4b3afb1..702ff49b5 100644 --- a/frontend/src/pages/xray/outbounds/useOutboundColumns.tsx +++ b/frontend/src/pages/xray/outbounds/useOutboundColumns.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, Dropdown, Popover, Tag, Tooltip } from 'antd'; +import { Button, Dropdown, Tag, Tooltip } from 'antd'; import { RetweetOutlined, MoreOutlined, @@ -8,8 +8,6 @@ import { DeleteOutlined, VerticalAlignTopOutlined, ThunderboltOutlined, - CheckCircleFilled, - CloseCircleFilled, LoadingOutlined, ArrowUpOutlined, ArrowDownOutlined, @@ -22,8 +20,8 @@ import { isUdpOutbound } from '@/hooks/useXraySetting'; import type { OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting'; import type { OutboundRow } from './outbounds-tab-types'; +import TestResultPopover from './TestResultPopover'; import { - hasBreakdown, isTesting, isUntestable, outboundAddresses, @@ -160,36 +158,7 @@ export function useOutboundColumns({ render: (_v, _record, index) => { const r = testResult(outboundTestStates, index); if (!r) return isTesting(outboundTestStates, index) ? : ; - return ( - -
- {r.success ? {r.delay} ms : {r.error || 'failed'}} - {r.mode && {String(r.mode).toUpperCase()}} -
- {hasBreakdown(r) && ( - <> - {(r.endpoints || []).map((ep) => ( -
- - {ep.address} - {ep.success ? `${ep.delay} ms` : ep.error || 'failed'} -
- ))} - - )} - - } - > - - {r.success ? : } - {r.success ? {r.delay} ms : failed} - -
- ); + return ; }, }, { diff --git a/frontend/src/schemas/xray.ts b/frontend/src/schemas/xray.ts index 32f2ef9ba..273814fd2 100644 --- a/frontend/src/schemas/xray.ts +++ b/frontend/src/schemas/xray.ts @@ -56,10 +56,18 @@ export const OutboundTrafficRowSchema = z.object({ export const OutboundTrafficListSchema = z.array(OutboundTrafficRowSchema); export const OutboundTestResultSchema = z.object({ + tag: z.string().optional(), success: z.boolean(), delay: z.number().optional(), error: z.string().optional(), mode: z.string().optional(), + // HTTP-mode extras: status answered by the test URL plus the httptrace + // timing breakdown (dial to local inbound / target TLS via the outbound / + // time to first byte). + httpStatus: z.number().optional(), + connectMs: z.number().optional(), + tlsMs: z.number().optional(), + ttfbMs: z.number().optional(), endpoints: z .array( z.object({ @@ -72,6 +80,9 @@ export const OutboundTestResultSchema = z.object({ .optional(), }).loose(); +// Batch results from /xray/testOutbounds, aligned with the request order. +export const OutboundTestResultListSchema = z.array(OutboundTestResultSchema); + export const RuleFormSchema = z.object({ domain: z.string(), ip: z.string(), diff --git a/internal/web/controller/xray_setting.go b/internal/web/controller/xray_setting.go index 30804df60..c3a5c6cc7 100644 --- a/internal/web/controller/xray_setting.go +++ b/internal/web/controller/xray_setting.go @@ -48,6 +48,7 @@ func (a *XraySettingController) initRouter(g *gin.RouterGroup) { g.POST("/update", a.updateSetting) g.POST("/resetOutboundsTraffic", a.resetOutboundsTraffic) g.POST("/testOutbound", a.testOutbound) + g.POST("/testOutbounds", a.testOutbounds) g.POST("/balancerStatus", a.balancerStatus) g.POST("/balancerOverride", a.balancerOverride) g.POST("/routeTest", a.routeTest) @@ -286,6 +287,39 @@ func (a *XraySettingController) testOutbound(c *gin.Context) { jsonObj(c, result, nil) } +// testOutbounds tests a batch of outbound configurations through one shared +// temp xray instance and returns an array of results in input order. +// Form "outbounds": JSON array of outbound configs (required). +// Optional form "allOutbounds": JSON array of all outbounds; used to resolve sockopt.dialerProxy dependencies. +// Optional form "mode": "tcp" for fast dial-only probes, anything else +// (default) for real HTTP requests routed through each outbound. +func (a *XraySettingController) testOutbounds(c *gin.Context) { + outboundsJSON := c.PostForm("outbounds") + allOutboundsJSON := c.PostForm("allOutbounds") + mode := c.PostForm("mode") + + if outboundsJSON == "" { + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), common.NewError("outbounds parameter is required")) + return + } + + // Load the test URL from server settings to prevent SSRF via user-controlled URLs + testURL, _ := a.SettingService.GetXrayOutboundTestUrl() + testURL, err := service.SanitizePublicHTTPURL(testURL, false) + if err != nil { + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + + results, err := a.OutboundService.TestOutbounds(outboundsJSON, testURL, allOutboundsJSON, mode) + if err != nil { + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + + jsonObj(c, results, nil) +} + // balancerStatus reports the live state (override + strategy picks) of the // balancer tags given as a comma-separated "tags" form field. func (a *XraySettingController) balancerStatus(c *gin.Context) { diff --git a/internal/web/service/outbound/outbound.go b/internal/web/service/outbound/outbound.go index 88f0183ce..8e259225a 100644 --- a/internal/web/service/outbound/outbound.go +++ b/internal/web/service/outbound/outbound.go @@ -4,17 +4,13 @@ import ( "encoding/json" "fmt" "net" - "net/http" - "os" "strconv" "sync" "time" - "github.com/mhsanaei/3x-ui/v3/internal/config" "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/logger" - "github.com/mhsanaei/3x-ui/v3/internal/util/json_util" "github.com/mhsanaei/3x-ui/v3/internal/xray" "gorm.io/gorm" @@ -24,11 +20,6 @@ import ( // It handles outbound traffic monitoring and statistics. type OutboundService struct{} -// httpTestSemaphore serialises HTTP-mode probes (each one spawns a temp xray -// instance, which is too expensive to run in parallel). TCP-mode probes are -// dial-only and don't need the semaphore. -var httpTestSemaphore sync.Mutex - func (s *OutboundService) AddTraffic(traffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (error, bool) { var err error db := database.GetDB() @@ -119,14 +110,25 @@ func (s *OutboundService) ResetOutboundTraffic(tag string) error { // TestOutboundResult represents the result of testing an outbound. // Delay is in milliseconds. Endpoints is only populated for TCP-mode -// probes; HTTP mode reports the round-trip delay measured by xray's -// burstObservatory probe. +// probes; HTTP mode reports the time of a real HTTP request routed +// through the outbound, with an optional timing breakdown. type TestOutboundResult struct { + Tag string `json:"tag,omitempty"` Success bool `json:"success"` Delay int64 `json:"delay"` Error string `json:"error,omitempty"` Mode string `json:"mode,omitempty"` + // HTTP-mode extras. Any HTTP response counts as reachable; HTTPStatus + // records what the test URL answered. ConnectMs is the dial to the local + // test inbound; TLSMs covers outbound-chain establishment + target TLS + // (https URLs only, since xray ACKs the SOCKS CONNECT before dialing + // upstream); TTFBMs is request start → first response byte. + HTTPStatus int `json:"httpStatus,omitempty"` + ConnectMs int64 `json:"connectMs,omitempty"` + TLSMs int64 `json:"tlsMs,omitempty"` + TTFBMs int64 `json:"ttfbMs,omitempty"` + Endpoints []TestEndpointResult `json:"endpoints,omitempty"` } @@ -139,31 +141,6 @@ type TestEndpointResult struct { Error string `json:"error,omitempty"` } -// TestOutbound dispatches to the chosen probe mode: -// - mode="tcp": dial the outbound's host:port directly. No xray spin-up, -// parallel-safe, ~100ms per endpoint. Doesn't validate the proxy -// protocol — only that the remote is reachable on TCP. -// - mode="" or "http": spin a temp xray instance, route a real HTTP -// request through it, return delay + a DNS/Connect/TLS/TTFB breakdown. -// Authoritative but expensive and serialised by httpTestSemaphore. -// -// allOutboundsJSON is only consulted in HTTP mode (it backs -// sockopt.dialerProxy chains during test). -func (s *OutboundService) TestOutbound(outboundJSON string, testURL string, allOutboundsJSON string, mode string) (*TestOutboundResult, error) { - if mode == "tcp" { - // A bare TCP dial only proves reachability for TCP-based proxies. - // UDP protocols (wireguard, hysteria, kcp/quic transports) ignore - // unauthenticated packets, so a raw dial can't tell "reachable" from - // "dead" — route them through the authoritative xray handshake probe. - var ob map[string]any - if json.Unmarshal([]byte(outboundJSON), &ob) == nil && outboundTransportIsUDP(ob) { - return s.testOutboundHTTP(outboundJSON, testURL, allOutboundsJSON) - } - return s.testOutboundTCP(outboundJSON) - } - return s.testOutboundHTTP(outboundJSON, testURL, allOutboundsJSON) -} - func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundResult, error) { var ob map[string]any if err := json.Unmarshal([]byte(outboundJSON), &ob); err != nil { @@ -172,12 +149,12 @@ func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundRes tag, _ := ob["tag"].(string) protocol, _ := ob["protocol"].(string) if protocol == "blackhole" || protocol == "freedom" || tag == "blocked" { - return &TestOutboundResult{Mode: "tcp", Success: false, Error: "Outbound has no testable endpoint"}, nil + return &TestOutboundResult{Tag: tag, Mode: "tcp", Success: false, Error: "Outbound has no testable endpoint"}, nil } endpoints := extractOutboundEndpoints(ob) if len(endpoints) == 0 { - return &TestOutboundResult{Mode: "tcp", Success: false, Error: "No testable endpoint"}, nil + return &TestOutboundResult{Tag: tag, Mode: "tcp", Success: false, Error: "No testable endpoint"}, nil } results := make([]TestEndpointResult, len(endpoints)) @@ -203,7 +180,7 @@ func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundRes } } - out := &TestOutboundResult{Mode: "tcp", Endpoints: results} + out := &TestOutboundResult{Tag: tag, Mode: "tcp", Endpoints: results} if bestDelay >= 0 { out.Success = true out.Delay = bestDelay @@ -312,276 +289,3 @@ func numAsInt(v any) int { } return 0 } - -// testOutboundHTTP spins up a temporary xray instance whose only job is -// to run a burstObservatory probe against the target outbound, then polls -// xray's metrics /debug/vars endpoint until that outbound is reported -// alive (success) or the deadline expires (failure). The probe lives -// inside xray, so the measured delay and any failure reason reflect what -// xray itself sees over the real proxy chain — no SOCKS round-trip on -// the client side. -func (s *OutboundService) testOutboundHTTP(outboundJSON string, testURL string, allOutboundsJSON string) (*TestOutboundResult, error) { - if testURL == "" { - testURL = "https://www.google.com/generate_204" - } - - if !httpTestSemaphore.TryLock() { - return &TestOutboundResult{ - Mode: "http", - Success: false, - Error: "Another outbound test is already running, please wait", - }, nil - } - defer httpTestSemaphore.Unlock() - - var testOutbound map[string]any - if err := json.Unmarshal([]byte(outboundJSON), &testOutbound); err != nil { - return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Invalid outbound JSON: %v", err)}, nil - } - outboundTag, _ := testOutbound["tag"].(string) - if outboundTag == "" { - return &TestOutboundResult{Mode: "http", Success: false, Error: "Outbound has no tag"}, nil - } - if protocol, _ := testOutbound["protocol"].(string); protocol == "blackhole" || outboundTag == "blocked" { - return &TestOutboundResult{Mode: "http", Success: false, Error: "Blocked/blackhole outbound cannot be tested"}, nil - } - - var allOutbounds []any - if allOutboundsJSON != "" { - if err := json.Unmarshal([]byte(allOutboundsJSON), &allOutbounds); err != nil { - return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Invalid allOutbounds JSON: %v", err)}, nil - } - } - // The outbound under test must be present in the config so burstObservatory - // has something with outboundTag to probe. allOutbounds is the template's - // outbounds (for dialerProxy chains); subscription outbounds are injected at - // runtime and aren't part of it, so without this the probe targets a tag that - // doesn't exist in the config and every test times out. Append (don't replace) - // so manual outbounds' dialerProxy chains keep resolving. - if !outboundsContainTag(allOutbounds, outboundTag) { - allOutbounds = append(allOutbounds, testOutbound) - } - - metricsPort, err := findAvailablePort() - if err != nil { - return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Failed to find available port: %v", err)}, nil - } - - testConfig := s.createTestConfig(outboundTag, allOutbounds, metricsPort, testURL) - - testConfigPath, err := createTestConfigPath() - if err != nil { - return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Failed to create test config path: %v", err)}, nil - } - defer os.Remove(testConfigPath) - - testProcess := xray.NewTestProcess(testConfig, testConfigPath) - defer func() { - if testProcess.IsRunning() { - testProcess.Stop() - } - }() - - if err := testProcess.Start(); err != nil { - return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Failed to start test xray instance: %v", err)}, nil - } - - if err := waitForPort(metricsPort, 5*time.Second); err != nil { - if !testProcess.IsRunning() { - result := testProcess.GetResult() - return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Xray process exited: %s", result)}, nil - } - return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Xray failed to start metrics listener: %v", err)}, nil - } - - if !testProcess.IsRunning() { - result := testProcess.GetResult() - return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Xray process exited: %s", result)}, nil - } - - return pollObservatoryResult(testProcess, metricsPort, outboundTag, 15*time.Second), nil -} - -// outboundsContainTag reports whether any outbound in the slice has the given tag. -func outboundsContainTag(outbounds []any, tag string) bool { - for _, ob := range outbounds { - if m, ok := ob.(map[string]any); ok { - if t, _ := m["tag"].(string); t == tag { - return true - } - } - } - return false -} - -func (s *OutboundService) createTestConfig(outboundTag string, allOutbounds []any, metricsPort int, probeURL string) *xray.Config { - 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" { - if settings, ok := outbound["settings"].(map[string]any); ok { - settings["noKernelTun"] = true - } else { - outbound["settings"] = map[string]any{"noKernelTun": true} - } - } - processedOutbounds[i] = outbound - } - outboundsJSON, _ := json.Marshal(processedOutbounds) - - routingJSON, _ := json.Marshal(map[string]any{ - "domainStrategy": "AsIs", - "rules": []any{}, - }) - - burstObservatoryJSON, _ := json.Marshal(map[string]any{ - "subjectSelector": []string{outboundTag}, - "pingConfig": map[string]any{ - "destination": probeURL, - "interval": "1s", - "connectivity": "", - "timeout": "10s", - "samplingCount": 1, - }, - }) - - metricsJSON, _ := json.Marshal(map[string]any{ - "tag": "test-metrics", - "listen": fmt.Sprintf("127.0.0.1:%d", metricsPort), - }) - - logConfig := map[string]any{ - "loglevel": "warning", - "access": "none", - "error": "", - "dnsLog": false, - } - logJSON, _ := json.Marshal(logConfig) - - cfg := &xray.Config{ - LogConfig: json_util.RawMessage(logJSON), - InboundConfigs: []xray.InboundConfig{}, - OutboundConfigs: json_util.RawMessage(string(outboundsJSON)), - RouterConfig: json_util.RawMessage(string(routingJSON)), - Policy: json_util.RawMessage(`{}`), - Stats: json_util.RawMessage(`{}`), - BurstObservatory: json_util.RawMessage(string(burstObservatoryJSON)), - Metrics: json_util.RawMessage(string(metricsJSON)), - } - - return cfg -} - -// observatoryEntry mirrors the per-outbound shape published by xray's -// observatory under /debug/vars. -type observatoryEntry struct { - Alive bool `json:"alive"` - Delay int64 `json:"delay"` - LastSeenTime int64 `json:"last_seen_time"` - LastTryTime int64 `json:"last_try_time"` - OutboundTag string `json:"outbound_tag"` -} - -func pollObservatoryResult(testProcess *xray.Process, metricsPort int, tag string, timeout time.Duration) *TestOutboundResult { - url := fmt.Sprintf("http://127.0.0.1:%d/debug/vars", metricsPort) - client := &http.Client{Timeout: 2 * time.Second} - deadline := time.Now().Add(timeout) - var lastEntry observatoryEntry - var sawEntry bool - for time.Now().Before(deadline) { - if !testProcess.IsRunning() { - result := testProcess.GetResult() - return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Xray process exited: %s", result)} - } - entry, ok := fetchObservatoryEntry(client, url, tag) - if ok { - if entry.Alive { - delay := entry.Delay - if delay <= 0 { - delay = 1 - } - return &TestOutboundResult{Mode: "http", Success: true, Delay: delay} - } - lastEntry = entry - sawEntry = true - } - time.Sleep(400 * time.Millisecond) - } - - msg := "Probe timed out — outbound did not become reachable (see Xray log for details)" - if sawEntry && lastEntry.LastTryTime > 0 { - msg = fmt.Sprintf("All probes failed (last attempt %ds ago; see Xray log for details)", time.Now().Unix()-lastEntry.LastTryTime) - } - return &TestOutboundResult{Mode: "http", Success: false, Error: msg} -} - -func fetchObservatoryEntry(client *http.Client, url, tag string) (observatoryEntry, bool) { - resp, err := client.Get(url) - if err != nil { - return observatoryEntry{}, false - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return observatoryEntry{}, false - } - var payload struct { - Observatory map[string]observatoryEntry `json:"observatory"` - } - if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { - return observatoryEntry{}, false - } - if entry, ok := payload.Observatory[tag]; ok { - return entry, true - } - for _, entry := range payload.Observatory { - if entry.OutboundTag == tag { - return entry, true - } - } - return observatoryEntry{}, false -} - -// 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 -} diff --git a/internal/web/service/outbound/probe_http.go b/internal/web/service/outbound/probe_http.go new file mode 100644 index 000000000..4bb16a29e --- /dev/null +++ b/internal/web/service/outbound/probe_http.go @@ -0,0 +1,552 @@ +package outbound + +import ( + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io/fs" + "net" + "net/http" + "net/http/httptrace" + "net/url" + "os" + "strconv" + "sync" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/config" + "github.com/mhsanaei/3x-ui/v3/internal/util/json_util" + "github.com/mhsanaei/3x-ui/v3/internal/xray" +) + +// HTTP-mode probing works by spinning up ONE temporary xray instance per +// batch: every outbound under test gets its own loopback SOCKS inbound plus +// an inboundTag→outboundTag routing rule, and the panel then issues a real, +// individually-timed HTTP request through each inbound. Measuring the request +// client-side (instead of polling xray's observatory) returns the moment the +// response lands, yields the actual HTTP status, and allows an httptrace +// timing breakdown — while the shared process keeps "Test All" at one xray +// spawn per batch instead of one per outbound. + +const ( + // httpProbeTimeout bounds one probe request end-to-end. + httpProbeTimeout = 10 * time.Second + // httpProbeConcurrency caps parallel probe requests within a batch — + // enough to keep a batch fast, low enough not to spike CPU with TLS + // handshakes on small VPSes. + httpProbeConcurrency = 16 + // batchPortsReadyTimeout bounds the wait for the temp instance to open + // its test inbounds. + batchPortsReadyTimeout = 10 * time.Second + // maxBatchItems caps one batch request; the frontend chunks below this. + maxBatchItems = 50 + // tcpBatchConcurrency caps parallel TCP-mode items in a batch (each item + // already dials its endpoints concurrently). + tcpBatchConcurrency = 8 + + defaultTestURL = "https://www.google.com/generate_204" +) + +// httpTestSemaphore serialises HTTP-mode batches (each spawns a temp xray +// instance, which is too expensive to run in parallel). TCP-mode probes are +// dial-only and don't need the semaphore. +var httpTestSemaphore sync.Mutex + +// batchProcess is the slice of xray.Process the batch engine needs; a seam +// so unit tests can stub the process without an xray binary. +type batchProcess interface { + Start() error + Stop() error + IsRunning() bool + GetResult() string +} + +var newBatchProcess = func(cfg *xray.Config, configPath string) batchProcess { + return xray.NewTestProcess(cfg, configPath) +} + +// httpBatchItem is one outbound inside an HTTP-mode batch. result is the +// pre-allocated entry in the caller's result slice, filled in place. +type httpBatchItem struct { + index int + tag string + outbound map[string]any + result *TestOutboundResult +} + +// TestOutbound probes a single outbound; legacy single-test API kept for the +// /testOutbound endpoint. Dispatch matches TestOutbounds: mode "tcp" dials +// the outbound's endpoints directly, anything else routes a real HTTP request +// through a temp xray instance (UDP-transport outbounds are always forced to +// the HTTP probe — a raw dial can't measure them). +func (s *OutboundService) TestOutbound(outboundJSON string, testURL string, allOutboundsJSON string, mode string) (*TestOutboundResult, error) { + var ob map[string]any + if err := json.Unmarshal([]byte(outboundJSON), &ob); err != nil { + m := "http" + if mode == "tcp" { + m = "tcp" + } + return &TestOutboundResult{Mode: m, Success: false, Error: fmt.Sprintf("Invalid outbound JSON: %v", err)}, nil + } + results := s.testOutboundsParsed([]map[string]any{ob}, testURL, allOutboundsJSON, mode) + return results[0], nil +} + +// TestOutbounds probes a JSON array of outbounds and returns one result per +// input, in input order, each carrying the outbound's tag. allOutboundsJSON +// supplies the config context (sockopt.dialerProxy chains); testURL falls +// back to the default probe URL when empty. +func (s *OutboundService) TestOutbounds(outboundsJSON string, testURL string, allOutboundsJSON string, mode string) ([]*TestOutboundResult, error) { + var raw []json.RawMessage + if err := json.Unmarshal([]byte(outboundsJSON), &raw); err != nil { + return nil, fmt.Errorf("invalid outbounds JSON: %v", err) + } + if len(raw) > maxBatchItems { + return nil, fmt.Errorf("too many outbounds in one request (max %d)", maxBatchItems) + } + items := make([]map[string]any, len(raw)) + for i, r := range raw { + var ob map[string]any + if err := json.Unmarshal(r, &ob); err == nil { + items[i] = ob + } + } + return s.testOutboundsParsed(items, testURL, allOutboundsJSON, mode), nil +} + +// testOutboundsParsed splits items into the TCP lane (direct dials, bounded +// worker pool) and the HTTP lane (one shared temp xray instance), runs both, +// and returns results aligned with items. A nil item marks unparseable input. +func (s *OutboundService) testOutboundsParsed(items []map[string]any, testURL string, allOutboundsJSON string, mode string) []*TestOutboundResult { + results := make([]*TestOutboundResult, len(items)) + + modeLabel := "http" + if mode == "tcp" { + modeLabel = "tcp" + } + + type tcpEntry struct { + idx int + ob map[string]any + } + var tcpLane []tcpEntry + var httpItems []*httpBatchItem + seenTags := make(map[string]bool) + + for i, ob := range items { + if ob == nil { + results[i] = &TestOutboundResult{Mode: modeLabel, Success: false, Error: "Invalid outbound JSON"} + continue + } + // A bare TCP dial only proves reachability for TCP-based proxies. + // UDP protocols (wireguard, hysteria, kcp/quic transports) ignore + // unauthenticated packets, so a raw dial can't tell "reachable" from + // "dead" — route them through the real xray probe. + if mode == "tcp" && !outboundTransportIsUDP(ob) { + tcpLane = append(tcpLane, tcpEntry{idx: i, ob: ob}) + continue + } + + tag, _ := ob["tag"].(string) + r := &TestOutboundResult{Tag: tag, Mode: "http"} + results[i] = r + protocol, _ := ob["protocol"].(string) + switch { + case tag == "": + r.Error = "Outbound has no tag" + case protocol == "blackhole" || tag == "blocked": + r.Error = "Blocked/blackhole outbound cannot be tested" + case protocol == "loopback": + r.Error = "Loopback outbound cannot be tested" + case seenTags[tag]: + r.Error = fmt.Sprintf("Duplicate outbound tag in batch: %s", tag) + default: + seenTags[tag] = true + httpItems = append(httpItems, &httpBatchItem{index: i, tag: tag, outbound: ob, result: r}) + } + } + + if len(tcpLane) > 0 { + var wg sync.WaitGroup + sem := make(chan struct{}, tcpBatchConcurrency) + for _, e := range tcpLane { + wg.Add(1) + go func(e tcpEntry) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + obJSON, err := json.Marshal(e.ob) + if err != nil { + tag, _ := e.ob["tag"].(string) + results[e.idx] = &TestOutboundResult{Tag: tag, Mode: "tcp", Success: false, Error: fmt.Sprintf("Invalid outbound JSON: %v", err)} + return + } + r, _ := s.testOutboundTCP(string(obJSON)) + results[e.idx] = r + }(e) + } + wg.Wait() + } + + if len(httpItems) == 0 { + return results + } + + failAll := func(msg string) { + for _, it := range httpItems { + it.result.Success = false + it.result.Error = msg + } + } + + var allOutbounds []any + if allOutboundsJSON != "" { + if err := json.Unmarshal([]byte(allOutboundsJSON), &allOutbounds); err != nil { + failAll(fmt.Sprintf("Invalid allOutbounds JSON: %v", err)) + return results + } + } + + if testURL == "" { + testURL = defaultTestURL + } + + if !httpTestSemaphore.TryLock() { + failAll("Another outbound test is already running, please wait") + return results + } + defer httpTestSemaphore.Unlock() + + retryPerItem, err := runHTTPProbeBatch(httpItems, allOutbounds, testURL) + if err == nil { + return results + } + if !retryPerItem || len(httpItems) == 1 { + failAll(err.Error()) + return results + } + // The shared process never came up — one structurally-bad outbound can + // poison the whole batch config. Retry each item in its own isolated + // instance so the broken outbound reports xray's real error and the + // rest still get tested. Serial: the poisoned case fails fast (~1s). + for _, it := range httpItems { + if _, ferr := runHTTPProbeBatch([]*httpBatchItem{it}, allOutbounds, testURL); ferr != nil { + it.result.Success = false + it.result.Error = ferr.Error() + } + } + return results +} + +// runHTTPProbeBatch makes one shared-process attempt for the given items, +// writing per-request outcomes into the items' results. It returns a non-nil +// error only when the process never became usable; retryPerItem reports +// whether splitting the batch into per-item instances could help (true for +// start failures / early exits that a poisoned config would explain, false +// for environmental failures like a missing binary or no free ports). +func runHTTPProbeBatch(items []*httpBatchItem, allOutbounds []any, testURL string) (retryPerItem bool, err error) { + ports, release, err := reserveLoopbackPorts(len(items)) + if err != nil { + return false, fmt.Errorf("Failed to reserve test ports: %v", err) + } + defer release() + + cfg := buildBatchTestConfig(items, allOutbounds, ports) + + configPath, err := createTestConfigPath() + if err != nil { + return false, fmt.Errorf("Failed to create test config path: %v", err) + } + defer os.Remove(configPath) + + proc := newBatchProcess(cfg, configPath) + defer func() { + if proc.IsRunning() { + proc.Stop() + } + }() + + // Free the reserved ports just before xray binds them; the window is + // milliseconds, and a lost race makes xray exit fast, which surfaces + // below and triggers the per-item retry with fresh ports. + release() + if err := proc.Start(); err != nil { + if errors.Is(err, fs.ErrNotExist) { + // Binary missing — per-item retries would all fail the same way. + return false, fmt.Errorf("Failed to start test xray instance: %v", err) + } + return true, fmt.Errorf("Failed to start test xray instance: %v", err) + } + + if err := waitForPortsReady(proc, ports, batchPortsReadyTimeout); err != nil { + return err.exited, err + } + + sem := make(chan struct{}, httpProbeConcurrency) + var wg sync.WaitGroup + for i := range items { + wg.Add(1) + go func(it *httpBatchItem, port int) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + probeThroughSocks(port, testURL, httpProbeTimeout, it.result) + }(items[i], ports[i]) + } + wg.Wait() + + if !proc.IsRunning() { + detail := proc.GetResult() + for _, it := range items { + if !it.result.Success { + it.result.Error = "Xray process exited: " + detail + } + } + } + return false, nil +} + +// portsReadyError distinguishes "process died" (a poisoned config — worth a +// per-item retry) from "ports never opened while alive" (environmental). +type portsReadyError struct { + msg string + exited bool +} + +func (e *portsReadyError) Error() string { return e.msg } + +// waitForPortsReady polls until every test inbound accepts connections, +// aborting as soon as the process exits. +func waitForPortsReady(proc batchProcess, ports []int, timeout time.Duration) *portsReadyError { + deadline := time.Now().Add(timeout) + for _, port := range ports { + for { + if !proc.IsRunning() { + return &portsReadyError{msg: "Xray process exited: " + proc.GetResult(), exited: true} + } + conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 100*time.Millisecond) + if err == nil { + conn.Close() + break + } + if time.Now().After(deadline) { + return &portsReadyError{msg: fmt.Sprintf("Xray failed to open test inbounds: port %d not ready after %v", port, timeout)} + } + time.Sleep(50 * time.Millisecond) + } + } + return nil +} + +// buildBatchTestConfig assembles the temp instance config: one loopback SOCKS +// inbound per tested outbound, a routing rule binding each inbound to its +// outbound tag, and the full outbound context so dialerProxy chains resolve. +func buildBatchTestConfig(items []*httpBatchItem, allOutbounds []any, ports []int) *xray.Config { + // allOutbounds is the template's outbound list; subscription outbounds + // are injected at runtime and aren't part of it, so append any tested + // outbound whose tag is missing. When a tested outbound's tag collides + // with a template outbound, the template version wins — same semantics + // as the pre-batch tester. + outbounds := make([]any, 0, len(allOutbounds)+len(items)) + outbounds = append(outbounds, allOutbounds...) + for _, it := range items { + if !outboundsContainTag(outbounds, it.tag) { + outbounds = append(outbounds, it.outbound) + } + } + for _, ob := range outbounds { + outbound, ok := ob.(map[string]any) + if !ok { + continue + } + // The temp instance must not touch kernel WireGuard devices. + if protocol, ok := outbound["protocol"].(string); ok && protocol == "wireguard" { + if settings, ok := outbound["settings"].(map[string]any); ok { + settings["noKernelTun"] = true + } else { + outbound["settings"] = map[string]any{"noKernelTun": true} + } + } + } + outboundsJSON, _ := json.Marshal(outbounds) + + inbounds := make([]xray.InboundConfig, len(items)) + rules := make([]any, len(items)) + for i, it := range items { + inTag := fmt.Sprintf("test-in-%d", i) + inbounds[i] = xray.InboundConfig{ + Listen: json_util.RawMessage(`"127.0.0.1"`), + Port: ports[i], + Protocol: "socks", + Settings: json_util.RawMessage(`{"auth":"noauth","udp":false}`), + Tag: inTag, + } + rules[i] = map[string]any{ + "type": "field", + "inboundTag": []string{inTag}, + "outboundTag": it.tag, + } + } + routingJSON, _ := json.Marshal(map[string]any{ + "domainStrategy": "AsIs", + "rules": rules, + }) + + logJSON, _ := json.Marshal(map[string]any{ + "loglevel": "warning", + "access": "none", + "error": "", + "dnsLog": false, + }) + + return &xray.Config{ + LogConfig: json_util.RawMessage(logJSON), + InboundConfigs: inbounds, + OutboundConfigs: json_util.RawMessage(outboundsJSON), + RouterConfig: json_util.RawMessage(routingJSON), + Policy: json_util.RawMessage(`{}`), + Stats: json_util.RawMessage(`{}`), + } +} + +// outboundsContainTag reports whether any outbound in the slice has the given tag. +func outboundsContainTag(outbounds []any, tag string) bool { + for _, ob := range outbounds { + if m, ok := ob.(map[string]any); ok { + if t, _ := m["tag"].(string); t == tag { + return true + } + } + } + return false +} + +// probeThroughSocks issues one timed GET through the local SOCKS inbound at +// the given port and fills result. Any HTTP response — including 4xx/5xx and +// unfollowed redirects — counts as reachable; only transport-level failures +// (refused, reset, timeout, proxy errors) are failures. Delay is request +// start → response headers; the test URL's hostname is resolved by xray +// (Go's SOCKS5 client sends the domain to the proxy), so DNS goes through +// the outbound too. +func probeThroughSocks(port int, testURL string, timeout time.Duration, result *TestOutboundResult) { + proxyURL := &url.URL{Scheme: "socks5", Host: net.JoinHostPort("127.0.0.1", strconv.Itoa(port))} + tr := &http.Transport{ + Proxy: http.ProxyURL(proxyURL), + DisableKeepAlives: true, + } + defer tr.CloseIdleConnections() + client := &http.Client{ + Transport: tr, + Timeout: timeout, + // A redirect would re-dial through the proxy and skew the timing; + // the 3xx itself already proves the outbound works. + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + } + + // Timing breakdown. ConnectStart/Done wrap the TCP dial to the local + // inbound (the SOCKS handshake isn't traced, and xray ACKs CONNECT + // before dialing upstream — so the real outbound establishment lands in + // the TLS phase for https URLs, or inside TTFB for plain http). + var ( + connStart, tlsStart time.Time + connDur, tlsDur, ttfbDur time.Duration + connDone, tlsDone, gotFirstRB bool + ) + start := time.Now() + trace := &httptrace.ClientTrace{ + ConnectStart: func(network, addr string) { + if connStart.IsZero() { + connStart = time.Now() + } + }, + ConnectDone: func(network, addr string, err error) { + if err == nil && !connDone && !connStart.IsZero() { + connDone = true + connDur = time.Since(connStart) + } + }, + TLSHandshakeStart: func() { + if tlsStart.IsZero() { + tlsStart = time.Now() + } + }, + TLSHandshakeDone: func(_ tls.ConnectionState, err error) { + if err == nil && !tlsDone && !tlsStart.IsZero() { + tlsDone = true + tlsDur = time.Since(tlsStart) + } + }, + GotFirstResponseByte: func() { + if !gotFirstRB { + gotFirstRB = true + ttfbDur = time.Since(start) + } + }, + } + + req, err := http.NewRequestWithContext(httptrace.WithClientTrace(context.Background(), trace), http.MethodGet, testURL, nil) + if err != nil { + result.Error = err.Error() + return + } + resp, err := client.Do(req) + delay := time.Since(start).Milliseconds() + if err != nil { + result.Error = err.Error() + return + } + resp.Body.Close() + + result.Success = true + result.Delay = max(delay, 1) + result.HTTPStatus = resp.StatusCode + if connDone { + result.ConnectMs = max(connDur.Milliseconds(), 1) + } + if tlsDone { + result.TLSMs = max(tlsDur.Milliseconds(), 1) + } + if gotFirstRB { + result.TTFBMs = max(ttfbDur.Milliseconds(), 1) + } +} + +// reserveLoopbackPorts grabs n free loopback ports and keeps the listeners +// open so nothing else claims them; release() frees them (idempotent — the +// caller releases right before starting xray and again via defer). +func reserveLoopbackPorts(n int) ([]int, func(), error) { + listeners := make([]net.Listener, 0, n) + release := func() { + for _, l := range listeners { + l.Close() + } + } + ports := make([]int, 0, n) + for range n { + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + release() + return nil, nil, err + } + listeners = append(listeners, l) + ports = append(ports, l.Addr().(*net.TCPAddr).Port) + } + return ports, release, 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 +} diff --git a/internal/web/service/outbound/probe_http_test.go b/internal/web/service/outbound/probe_http_test.go new file mode 100644 index 000000000..ab58c3118 --- /dev/null +++ b/internal/web/service/outbound/probe_http_test.go @@ -0,0 +1,470 @@ +package outbound + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "net" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/xray" +) + +// stubProcess implements batchProcess without an xray binary. When serveSocks +// is set, Start opens a minimal SOCKS5 server on every inbound port from the +// config, so probes run against a real tunnel. +type stubProcess struct { + cfg *xray.Config + startErr error + result string + serveSocks bool + + running bool + listeners []net.Listener +} + +func (p *stubProcess) Start() error { + if p.startErr != nil { + return p.startErr + } + for _, in := range p.cfg.InboundConfigs { + l, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", in.Port)) + if err != nil { + return err + } + p.listeners = append(p.listeners, l) + if p.serveSocks { + go serveStubSocks(l) + } + } + p.running = true + return nil +} + +func (p *stubProcess) Stop() error { + for _, l := range p.listeners { + l.Close() + } + p.running = false + return nil +} + +func (p *stubProcess) IsRunning() bool { return p.running } +func (p *stubProcess) GetResult() string { + if p.result != "" { + return p.result + } + return "stub exited" +} + +// serveStubSocks answers SOCKS5 no-auth CONNECTs and pipes to the requested +// target — just enough protocol for net/http's socks5 client. +func serveStubSocks(l net.Listener) { + for { + conn, err := l.Accept() + if err != nil { + return + } + go func(c net.Conn) { + defer c.Close() + hello := make([]byte, 2) + if _, err := io.ReadFull(c, hello); err != nil { + return + } + methods := make([]byte, hello[1]) + if _, err := io.ReadFull(c, methods); err != nil { + return + } + c.Write([]byte{0x05, 0x00}) + hdr := make([]byte, 4) + if _, err := io.ReadFull(c, hdr); err != nil { + return + } + var host string + switch hdr[3] { + case 0x01: + b := make([]byte, 4) + io.ReadFull(c, b) + host = net.IP(b).String() + case 0x03: + lb := make([]byte, 1) + io.ReadFull(c, lb) + b := make([]byte, lb[0]) + io.ReadFull(c, b) + host = string(b) + case 0x04: + b := make([]byte, 16) + io.ReadFull(c, b) + host = net.IP(b).String() + default: + return + } + pb := make([]byte, 2) + if _, err := io.ReadFull(c, pb); err != nil { + return + } + port := int(pb[0])<<8 | int(pb[1]) + upstream, err := net.Dial("tcp", net.JoinHostPort(host, strconv.Itoa(port))) + if err != nil { + c.Write([]byte{0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0}) + return + } + defer upstream.Close() + c.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0}) + go io.Copy(upstream, c) + io.Copy(c, upstream) + }(conn) + } +} + +func withStubProcess(t *testing.T, factory func(cfg *xray.Config, configPath string) batchProcess) { + t.Helper() + // createTestConfigPath writes into the bin folder, which doesn't exist + // when running tests from the package directory. + t.Setenv("XUI_BIN_FOLDER", t.TempDir()) + orig := newBatchProcess + newBatchProcess = factory + t.Cleanup(func() { newBatchProcess = orig }) +} + +func mustJSON(t *testing.T, v any) string { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return string(b) +} + +func TestBuildBatchTestConfig(t *testing.T) { + items := []*httpBatchItem{ + {tag: "wg-sub", outbound: map[string]any{"tag": "wg-sub", "protocol": "wireguard"}}, + {tag: "proxy-a", outbound: map[string]any{"tag": "proxy-a", "protocol": "vless"}}, + } + allOutbounds := []any{ + map[string]any{"tag": "direct", "protocol": "freedom", "settings": map[string]any{}}, + map[string]any{"tag": "proxy-a", "protocol": "vless", "settings": map[string]any{"address": "a.example.com"}}, + } + ports := []int{61001, 61002} + + cfg := buildBatchTestConfig(items, allOutbounds, ports) + raw, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal config: %v", err) + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("unmarshal config: %v", err) + } + + inbounds, _ := m["inbounds"].([]any) + if len(inbounds) != 2 { + t.Fatalf("expected 2 inbounds, got %d", len(inbounds)) + } + for i, raw := range inbounds { + in := raw.(map[string]any) + if got := in["tag"]; got != fmt.Sprintf("test-in-%d", i) { + t.Errorf("inbound %d tag = %v", i, got) + } + if got := int(in["port"].(float64)); got != ports[i] { + t.Errorf("inbound %d port = %d, want %d", i, got, ports[i]) + } + if got := in["protocol"]; got != "socks" { + t.Errorf("inbound %d protocol = %v", i, got) + } + if got := in["listen"]; got != "127.0.0.1" { + t.Errorf("inbound %d listen = %v", i, got) + } + settings := in["settings"].(map[string]any) + if settings["auth"] != "noauth" || settings["udp"] != false { + t.Errorf("inbound %d settings = %v", i, settings) + } + } + + routing := m["routing"].(map[string]any) + rules, _ := routing["rules"].([]any) + if len(rules) != 2 { + t.Fatalf("expected 2 routing rules, got %d", len(rules)) + } + wantTags := []string{"wg-sub", "proxy-a"} + for i, raw := range rules { + rule := raw.(map[string]any) + inTags := rule["inboundTag"].([]any) + if len(inTags) != 1 || inTags[0] != fmt.Sprintf("test-in-%d", i) { + t.Errorf("rule %d inboundTag = %v", i, inTags) + } + if rule["outboundTag"] != wantTags[i] { + t.Errorf("rule %d outboundTag = %v, want %s", i, rule["outboundTag"], wantTags[i]) + } + } + + outbounds, _ := m["outbounds"].([]any) + if len(outbounds) != 3 { + t.Fatalf("expected 3 outbounds (wg-sub appended once, proxy-a deduped), got %d", len(outbounds)) + } + var wg map[string]any + for _, raw := range outbounds { + ob := raw.(map[string]any) + if ob["tag"] == "wg-sub" { + wg = ob + } + } + if wg == nil { + t.Fatal("wg-sub not appended to outbounds") + } + if settings, _ := wg["settings"].(map[string]any); settings == nil || settings["noKernelTun"] != true { + t.Errorf("wireguard settings missing noKernelTun: %v", wg["settings"]) + } + + if m["burstObservatory"] != nil { + t.Errorf("burstObservatory should not be set, got %v", m["burstObservatory"]) + } + if m["metrics"] != nil { + t.Errorf("metrics should not be set, got %v", m["metrics"]) + } +} + +func TestTestOutboundsPrevalidationAndOrdering(t *testing.T) { + calls := 0 + withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess { + calls++ + return &stubProcess{cfg: cfg, startErr: errors.New("boom")} + }) + + batch := mustJSON(t, []any{ + map[string]any{"protocol": "vless"}, // no tag + map[string]any{"tag": "bh", "protocol": "blackhole"}, // blackhole + map[string]any{"tag": "loop", "protocol": "loopback"}, // loopback + map[string]any{"tag": "a", "protocol": "socks"}, // valid + map[string]any{"tag": "a", "protocol": "vless"}, // duplicate + }) + results, err := (&OutboundService{}).TestOutbounds(batch, "http://example.invalid/gen", "", "http") + if err != nil { + t.Fatalf("TestOutbounds: %v", err) + } + if len(results) != 5 { + t.Fatalf("expected 5 results, got %d", len(results)) + } + wantErrs := []string{ + "Outbound has no tag", + "Blocked/blackhole outbound cannot be tested", + "Loopback outbound cannot be tested", + "Failed to start test xray instance: boom", + "Duplicate outbound tag in batch: a", + } + for i, want := range wantErrs { + if results[i].Success { + t.Errorf("result %d unexpectedly succeeded", i) + } + if results[i].Error != want { + t.Errorf("result %d error = %q, want %q", i, results[i].Error, want) + } + } + if results[3].Tag != "a" || results[4].Tag != "a" || results[1].Tag != "bh" { + t.Errorf("tags not propagated: %+v", results) + } + // Single valid item → no per-item fallback round. + if calls != 1 { + t.Errorf("process spawned %d times, want 1", calls) + } +} + +func TestTestOutboundsFallbackOnStartFailure(t *testing.T) { + calls := 0 + withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess { + calls++ + return &stubProcess{cfg: cfg, startErr: errors.New("boom")} + }) + + batch := mustJSON(t, []any{ + map[string]any{"tag": "a", "protocol": "socks"}, + map[string]any{"tag": "b", "protocol": "vless"}, + }) + results, err := (&OutboundService{}).TestOutbounds(batch, "http://example.invalid/gen", "", "http") + if err != nil { + t.Fatalf("TestOutbounds: %v", err) + } + for i, r := range results { + if r.Success || r.Error != "Failed to start test xray instance: boom" { + t.Errorf("result %d = %+v, want start failure", i, r) + } + } + // 1 shared attempt + 2 isolated fallback attempts. + if calls != 3 { + t.Errorf("process spawned %d times, want 3", calls) + } +} + +func TestTestOutboundsNoFallbackWhenBinaryMissing(t *testing.T) { + calls := 0 + withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess { + calls++ + return &stubProcess{cfg: cfg, startErr: &fs.PathError{Op: "exec", Path: "xray", Err: fs.ErrNotExist}} + }) + + batch := mustJSON(t, []any{ + map[string]any{"tag": "a", "protocol": "socks"}, + map[string]any{"tag": "b", "protocol": "vless"}, + }) + results, err := (&OutboundService{}).TestOutbounds(batch, "http://example.invalid/gen", "", "http") + if err != nil { + t.Fatalf("TestOutbounds: %v", err) + } + for i, r := range results { + if r.Success || !strings.HasPrefix(r.Error, "Failed to start test xray instance:") { + t.Errorf("result %d = %+v, want start failure", i, r) + } + } + if calls != 1 { + t.Errorf("process spawned %d times, want 1 (no fallback for missing binary)", calls) + } +} + +func TestTestOutboundsSemaphoreBusy(t *testing.T) { + withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess { + t.Fatal("process must not be spawned while semaphore is held") + return nil + }) + + httpTestSemaphore.Lock() + defer httpTestSemaphore.Unlock() + + batch := mustJSON(t, []any{map[string]any{"tag": "a", "protocol": "socks"}}) + results, err := (&OutboundService{}).TestOutbounds(batch, "", "", "http") + if err != nil { + t.Fatalf("TestOutbounds: %v", err) + } + if results[0].Success || results[0].Error != "Another outbound test is already running, please wait" { + t.Errorf("result = %+v, want busy error", results[0]) + } +} + +func TestTestOutboundsInputValidation(t *testing.T) { + s := &OutboundService{} + if _, err := s.TestOutbounds("not json", "", "", "tcp"); err == nil { + t.Error("expected error for invalid JSON") + } + + big := make([]any, maxBatchItems+1) + for i := range big { + big[i] = map[string]any{"tag": fmt.Sprintf("t%d", i), "protocol": "socks"} + } + if _, err := s.TestOutbounds(mustJSON(t, big), "", "", "tcp"); err == nil { + t.Error("expected error for oversized batch") + } + + results, err := s.TestOutbounds("[]", "", "", "tcp") + if err != nil || len(results) != 0 { + t.Errorf("empty batch: results=%v err=%v", results, err) + } +} + +func TestTestOutboundsTCPLane(t *testing.T) { + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer l.Close() + go func() { + for { + conn, err := l.Accept() + if err != nil { + return + } + conn.Close() + } + }() + port := l.Addr().(*net.TCPAddr).Port + + batch := mustJSON(t, []any{map[string]any{ + "tag": "t1", + "protocol": "socks", + "settings": map[string]any{"servers": []any{map[string]any{"address": "127.0.0.1", "port": port}}}, + }}) + results, err := (&OutboundService{}).TestOutbounds(batch, "", "", "tcp") + if err != nil { + t.Fatalf("TestOutbounds: %v", err) + } + r := results[0] + if !r.Success || r.Mode != "tcp" || r.Tag != "t1" || len(r.Endpoints) != 1 { + t.Errorf("unexpected tcp result: %+v", r) + } +} + +func TestTestOutboundsHTTPBatchThroughStubSocks(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + var proc *stubProcess + calls := 0 + withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess { + calls++ + proc = &stubProcess{cfg: cfg, serveSocks: true} + return proc + }) + + batch := mustJSON(t, []any{ + map[string]any{"tag": "a", "protocol": "vless"}, + map[string]any{"tag": "b", "protocol": "trojan"}, + }) + results, err := (&OutboundService{}).TestOutbounds(batch, srv.URL, "", "http") + if err != nil { + t.Fatalf("TestOutbounds: %v", err) + } + if calls != 1 { + t.Fatalf("process spawned %d times, want 1", calls) + } + for i, r := range results { + if !r.Success { + t.Fatalf("result %d failed: %+v", i, r) + } + if r.HTTPStatus != http.StatusNoContent { + t.Errorf("result %d status = %d, want 204", i, r.HTTPStatus) + } + if r.Delay < 1 || r.ConnectMs < 1 || r.TTFBMs < 1 { + t.Errorf("result %d timing not populated: %+v", i, r) + } + if r.TLSMs != 0 { + t.Errorf("result %d TLSMs = %d, want 0 for plain http", i, r.TLSMs) + } + if r.Mode != "http" { + t.Errorf("result %d mode = %q", i, r.Mode) + } + } + if proc.IsRunning() { + t.Error("temp process not stopped after batch") + } +} + +func TestProbeThroughSocksTransportFailure(t *testing.T) { + // A listener that accepts and immediately closes — SOCKS handshake dies. + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer l.Close() + go func() { + for { + conn, err := l.Accept() + if err != nil { + return + } + conn.Close() + } + }() + + var result TestOutboundResult + probeThroughSocks(l.Addr().(*net.TCPAddr).Port, "http://127.0.0.1:9/", 2*time.Second, &result) + if result.Success || result.Error == "" { + t.Errorf("expected transport failure, got %+v", result) + } +} diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index e781bb81e..8a0adf597 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -1384,6 +1384,10 @@ "testError": "فشل اختبار المخرج", "testModeTooltip": "TCP: فحص dial سريع. HTTP: طلب كامل عبر xray.", "testAll": "اختبار الكل", + "httpStatus": "حالة HTTP", + "breakdownConnect": "اتصال البروكسي", + "breakdownTls": "TLS عبر الصادر", + "breakdownTtfb": "أول بايت", "nordvpn": "NordVPN", "accessToken": "رمز الوصول", "country": "الدولة", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index 128a204fd..2a9aa548b 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -1387,6 +1387,10 @@ "testError": "Failed to test outbound", "testModeTooltip": "TCP: fast dial-only probe. HTTP: full request through xray.", "testAll": "Test all", + "httpStatus": "HTTP status", + "breakdownConnect": "Proxy connect", + "breakdownTls": "TLS via outbound", + "breakdownTtfb": "First byte", "nordvpn": "NordVPN", "accessToken": "Access Token", "country": "Country", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index eddf5562f..3ad286c53 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -1384,6 +1384,10 @@ "testError": "Error al probar la salida", "testModeTooltip": "TCP: sonda rápida solo de dial. HTTP: petición completa a través de xray.", "testAll": "Probar todo", + "httpStatus": "Estado HTTP", + "breakdownConnect": "Conexión al proxy", + "breakdownTls": "TLS vía salida", + "breakdownTtfb": "Primer byte", "nordvpn": "NordVPN", "accessToken": "Token de acceso", "country": "País", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index e9b08bc32..2939b4d93 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -1384,6 +1384,10 @@ "testError": "خطا در تست خروجی", "testModeTooltip": "TCP: فقط dial سریع. HTTP: درخواست کامل از طریق xray.", "testAll": "تست همه", + "httpStatus": "وضعیت HTTP", + "breakdownConnect": "اتصال پروکسی", + "breakdownTls": "TLS از طریق خروجی", + "breakdownTtfb": "اولین بایت", "nordvpn": "NordVPN", "accessToken": "توکن دسترسی", "country": "کشور", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index aa3273be4..83942cf19 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -1384,6 +1384,10 @@ "testError": "Gagal menguji outbound", "testModeTooltip": "TCP: probe dial-only cepat. HTTP: permintaan penuh via xray.", "testAll": "Tes semua", + "httpStatus": "Status HTTP", + "breakdownConnect": "Koneksi proxy", + "breakdownTls": "TLS melalui outbound", + "breakdownTtfb": "Byte pertama", "nordvpn": "NordVPN", "accessToken": "Token Akses", "country": "Negara", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index a0bbb3f11..f13e02bb1 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -1384,6 +1384,10 @@ "testError": "アウトバウンドのテストに失敗しました", "testModeTooltip": "TCP: 高速 dial-only プローブ。HTTP: xray を経由した完全リクエスト。", "testAll": "すべてテスト", + "httpStatus": "HTTPステータス", + "breakdownConnect": "プロキシ接続", + "breakdownTls": "アウトバウンド経由のTLS", + "breakdownTtfb": "最初のバイト", "nordvpn": "NordVPN", "accessToken": "アクセストークン", "country": "国", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index b52e6c0bb..90ecfb5ad 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -1384,6 +1384,10 @@ "testError": "Falha ao testar saída", "testModeTooltip": "TCP: sondagem rápida apenas de dial. HTTP: requisição completa pelo xray.", "testAll": "Testar todos", + "httpStatus": "Status HTTP", + "breakdownConnect": "Conexão do proxy", + "breakdownTls": "TLS via saída", + "breakdownTtfb": "Primeiro byte", "nordvpn": "NordVPN", "accessToken": "Token de Acesso", "country": "País", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index 60a85ca59..d3ac01f07 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -1384,6 +1384,10 @@ "testError": "Не удалось протестировать исходящее подключение", "testModeTooltip": "TCP: быстрый dial-only probe. HTTP: полный запрос через xray.", "testAll": "Тестировать все", + "httpStatus": "HTTP-статус", + "breakdownConnect": "Подключение к прокси", + "breakdownTls": "TLS через исходящий", + "breakdownTtfb": "Первый байт", "nordvpn": "NordVPN", "accessToken": "Токен доступа", "country": "Страна", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 0c10b3e96..39e0eb598 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -1385,6 +1385,10 @@ "testError": "Giden bağlantı test edilemedi", "testModeTooltip": "TCP: hızlı sadece arama (dial-only) testi. HTTP: Xray üzerinden tam istek.", "testAll": "Tümünü Test Et", + "httpStatus": "HTTP durumu", + "breakdownConnect": "Proxy bağlantısı", + "breakdownTls": "Giden üzerinden TLS", + "breakdownTtfb": "İlk bayt", "nordvpn": "NordVPN", "accessToken": "Erişim Jetonu", "country": "Ülke", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index a18beaa24..7b765fe54 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -1384,6 +1384,10 @@ "testError": "Не вдалося протестувати вихідне з'єднання", "testModeTooltip": "TCP: швидкий dial-only probe. HTTP: повний запит через xray.", "testAll": "Тестувати всі", + "httpStatus": "HTTP-статус", + "breakdownConnect": "Підключення до проксі", + "breakdownTls": "TLS через вихідний", + "breakdownTtfb": "Перший байт", "nordvpn": "NordVPN", "accessToken": "Токен доступу", "country": "Країна", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 751eb66d8..86f7d4385 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -1384,6 +1384,10 @@ "testError": "Không thể kiểm tra đầu ra", "testModeTooltip": "TCP: probe dial nhanh. HTTP: yêu cầu đầy đủ qua xray.", "testAll": "Kiểm tra tất cả", + "httpStatus": "Trạng thái HTTP", + "breakdownConnect": "Kết nối proxy", + "breakdownTls": "TLS qua outbound", + "breakdownTtfb": "Byte đầu tiên", "nordvpn": "NordVPN", "accessToken": "Mã truy cập", "country": "Quốc gia", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 70969ac38..cc0081595 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -1384,6 +1384,10 @@ "testError": "测试出站失败", "testModeTooltip": "TCP: 快速 dial-only 探测。HTTP: 通过 xray 的完整请求。", "testAll": "全部测试", + "httpStatus": "HTTP 状态", + "breakdownConnect": "代理连接", + "breakdownTls": "经由出站的 TLS", + "breakdownTtfb": "首字节", "nordvpn": "NordVPN", "accessToken": "访问令牌", "country": "国家", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index f6cdeb836..b5502f503 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -1384,6 +1384,10 @@ "testError": "測試出站失敗", "testModeTooltip": "TCP: 快速 dial-only 探測。HTTP: 透過 xray 的完整請求。", "testAll": "全部測試", + "httpStatus": "HTTP 狀態", + "breakdownConnect": "代理連線", + "breakdownTls": "經由出站的 TLS", + "breakdownTtfb": "首位元組", "nordvpn": "NordVPN", "accessToken": "訪問令牌", "country": "國家",