Prechádzať zdrojové kódy

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 1 deň pred
rodič
commit
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": "國家",