Bläddra i källkod

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.
MHSanaei 23 timmar sedan
förälder
incheckning
5716ae5987

+ 40 - 0
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": [

+ 51 - 20
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<OutboundTestResult> => {
+  // 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<OutboundTestResult[]> => {
+      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(() => {

+ 11 - 0
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',

+ 2 - 6
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({
             <span className="traffic-down">↓ {SizeFormatter.sizeFormat(trafficFor(outboundsTraffic, record).down)}</span>
             <span className="card-test">
               {testResult(outboundTestStates, index) ? (
-                <span className={testResult(outboundTestStates, index)!.success ? 'pill-ok' : 'pill-fail'}>
-                  {testResult(outboundTestStates, index)!.success ? <CheckCircleFilled /> : <CloseCircleFilled />}
-                  {testResult(outboundTestStates, index)!.success ? <span>{testResult(outboundTestStates, index)!.delay}&nbsp;ms</span> : <span>failed</span>}
-                </span>
+                <TestResultPopover result={testResult(outboundTestStates, index)!} />
               ) : isTesting(outboundTestStates, index) ? (
                 <LoadingOutlined />
               ) : null}

+ 19 - 0
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;
 }

+ 4 - 38
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) ? <LoadingOutlined /> : <span className="empty">—</span>;
-    return (
-      <Popover
-        placement="topLeft"
-        rootClassName="outbound-test-popover"
-        content={
-          <div className="timing-breakdown">
-            <div className={`td-head ${r.success ? 'ok' : 'fail'}`}>
-              {r.success ? <span>{r.delay} ms</span> : <span>{r.error || 'failed'}</span>}
-              {r.mode && <span className="mode-badge">{String(r.mode).toUpperCase()}</span>}
-            </div>
-            {hasBreakdown(r) && (
-              <>
-                {(r.endpoints || []).map((ep) => (
-                  <div key={ep.address} className="endpoint-row">
-                    <span className={ep.success ? 'dot-ok' : 'dot-fail'}>●</span>
-                    <span className="ep-addr">{ep.address}</span>
-                    <span className="ep-meta">{ep.success ? `${ep.delay} ms` : ep.error || 'failed'}</span>
-                  </div>
-                ))}
-              </>
-            )}
-          </div>
-        }
-      >
-        <span className={r.success ? 'pill-ok' : 'pill-fail'}>
-          {r.success ? <CheckCircleFilled /> : <CloseCircleFilled />}
-          {r.success ? <span>{r.delay}&nbsp;ms</span> : <span>failed</span>}
-        </span>
-      </Popover>
-    );
+    return <TestResultPopover result={r} />;
   };
 
   const testButton = (record: OutboundRow) => {

+ 68 - 0
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 (
+    <Popover
+      placement="topLeft"
+      rootClassName="outbound-test-popover"
+      content={
+        <div className="timing-breakdown">
+          <div className={`td-head ${r.success ? 'ok' : 'fail'}`}>
+            {r.success ? <span>{r.delay} ms</span> : <span>{r.error || 'failed'}</span>}
+            {r.mode && <span className="mode-badge">{String(r.mode).toUpperCase()}</span>}
+          </div>
+          {(r.endpoints || []).map((ep) => (
+            <div key={ep.address} className="endpoint-row">
+              <span className={ep.success ? 'dot-ok' : 'dot-fail'}>●</span>
+              <span className="ep-addr">{ep.address}</span>
+              <span className="ep-meta">{ep.success ? `${ep.delay} ms` : ep.error || 'failed'}</span>
+            </div>
+          ))}
+          {breakdown.map((row) => (
+            <div key={row.key} className="breakdown-row">
+              <span className="bd-label">{row.label}</span>
+              <span className="bd-value">{row.value}</span>
+            </div>
+          ))}
+        </div>
+      }
+    >
+      {children ?? (
+        <span className={r.success ? 'pill-ok' : 'pill-fail'}>
+          {r.success ? <CheckCircleFilled /> : <CloseCircleFilled />}
+          {r.success ? <span>{r.delay}&nbsp;ms</span> : <span>failed</span>}
+        </span>
+      )}
+    </Popover>
+  );
+}

+ 0 - 6
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 };

+ 3 - 34
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) ? <LoadingOutlined /> : <span className="empty">—</span>;
-          return (
-            <Popover
-              placement="topLeft"
-              rootClassName="outbound-test-popover"
-              content={
-                <div className="timing-breakdown">
-                  <div className={`td-head ${r.success ? 'ok' : 'fail'}`}>
-                    {r.success ? <span>{r.delay} ms</span> : <span>{r.error || 'failed'}</span>}
-                    {r.mode && <span className="mode-badge">{String(r.mode).toUpperCase()}</span>}
-                  </div>
-                  {hasBreakdown(r) && (
-                    <>
-                      {(r.endpoints || []).map((ep) => (
-                        <div key={ep.address} className="endpoint-row">
-                          <span className={ep.success ? 'dot-ok' : 'dot-fail'}>●</span>
-                          <span className="ep-addr">{ep.address}</span>
-                          <span className="ep-meta">{ep.success ? `${ep.delay} ms` : ep.error || 'failed'}</span>
-                        </div>
-                      ))}
-                    </>
-                  )}
-                </div>
-              }
-            >
-              <span className={r.success ? 'pill-ok' : 'pill-fail'}>
-                {r.success ? <CheckCircleFilled /> : <CloseCircleFilled />}
-                {r.success ? <span>{r.delay}&nbsp;ms</span> : <span>failed</span>}
-              </span>
-            </Popover>
-          );
+          return <TestResultPopover result={r} />;
         },
       },
       {

+ 11 - 0
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(),

+ 34 - 0
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) {

+ 16 - 312
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
-}

+ 552 - 0
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
+}

+ 470 - 0
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)
+	}
+}

+ 4 - 0
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": "الدولة",

+ 4 - 0
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",

+ 4 - 0
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",

+ 4 - 0
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": "کشور",

+ 4 - 0
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",

+ 4 - 0
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": "国",

+ 4 - 0
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",

+ 4 - 0
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": "Страна",

+ 4 - 0
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",

+ 4 - 0
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": "Країна",

+ 4 - 0
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",

+ 4 - 0
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": "国家",

+ 4 - 0
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": "國家",