Sfoglia il codice sorgente

feat: Add outbound egress metadata (IP + country) (#5886)

* Add outbound egress metadata

Show egress IP and country information for outbound HTTP tests. The probe reuses the temporary SOCKS route from the existing HTTP test and fetches Cloudflare trace metadata after the reachability check succeeds.

The outbound list now adds separate Egress and Country columns, hides egress IPs until the user reveals them, and marks Cloudflare WARP results with an orange cloud pill. Mobile cards keep the same data compact by placing the country and IPv4/IPv6 values on separate lines.

Validation: npm run typecheck; npm run lint; npm run build; go test ./internal/web/service/outbound

* Use context-aware DNS lookup for egress trace

* Address outbound egress review feedback

Restore the Real Delay selector and TCP default so the egress metadata change does not remove an existing test mode.

Keep HTTP probe tests hermetic by stubbing egress trace lookups, run IPv4 and IPv6 trace fetches concurrently with a shorter diagnostic timeout, scope mobile IP reveal state per row, support keyboard activation for reveal toggles, and treat WARP+ trace values as WARP-like.
isultanov99 9 ore fa
parent
commit
30f6bc1833

+ 18 - 0
frontend/src/pages/xray/outbounds/CountryPill.tsx

@@ -0,0 +1,18 @@
+import { CloudOutlined } from '@ant-design/icons';
+
+interface CountryPillProps {
+  flag: string;
+  name: string;
+  warp?: string;
+}
+
+export default function CountryPill({ flag, name, warp }: CountryPillProps) {
+  const isWarp = !!warp && warp.toLowerCase() !== 'off';
+  return (
+    <span className={isWarp ? 'country-pill warp-on' : 'country-pill'}>
+      {isWarp && <CloudOutlined className="warp-cloud-icon" />}
+      {flag && <span>{flag}</span>}
+      <span>{name}</span>
+    </span>
+  );
+}

+ 57 - 1
frontend/src/pages/xray/outbounds/OutboundCardList.tsx

@@ -1,3 +1,4 @@
+import { useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { Button, Dropdown, Tag, Tooltip } from 'antd';
 import { Button, Dropdown, Tag, Tooltip } from 'antd';
 import {
 import {
@@ -9,15 +10,21 @@ import {
   ThunderboltOutlined,
   ThunderboltOutlined,
   LoadingOutlined,
   LoadingOutlined,
   ExportOutlined,
   ExportOutlined,
+  EyeInvisibleOutlined,
+  EyeOutlined,
 } from '@ant-design/icons';
 } from '@ant-design/icons';
 
 
 import { SizeFormatter } from '@/utils';
 import { SizeFormatter } from '@/utils';
+import { activateOnKey } from '@/utils/a11y';
 import { OutboundProtocols as Protocols } from '@/schemas/primitives';
 import { OutboundProtocols as Protocols } from '@/schemas/primitives';
 import type { OutboundTestMode, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
 import type { OutboundTestMode, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
 
 
 import type { OutboundRow } from './outbounds-tab-types';
 import type { OutboundRow } from './outbounds-tab-types';
+import CountryPill from './CountryPill';
 import TestResultPopover from './TestResultPopover';
 import TestResultPopover from './TestResultPopover';
 import {
 import {
+  countryFlag,
+  countryName,
   isTesting,
   isTesting,
   isUntestable,
   isUntestable,
   outboundAddresses,
   outboundAddresses,
@@ -49,7 +56,55 @@ export default function OutboundCardList({
   confirmDelete,
   confirmDelete,
   onTest,
   onTest,
 }: OutboundCardListProps) {
 }: OutboundCardListProps) {
-  const { t } = useTranslation();
+  const { t, i18n } = useTranslation();
+  const [showEgressIp, setShowEgressIp] = useState<Record<string, boolean>>({});
+
+  const setCardEgressVisible = (key: string, visible: boolean) => {
+    setShowEgressIp((prev) => ({ ...prev, [key]: visible }));
+  };
+
+  const renderEgress = (index: number, rowKey: string) => {
+    const result = testResult(outboundTestStates, index);
+    const egress = result?.egress;
+    const isEgressVisible = !!showEgressIp[rowKey];
+    const flag = countryFlag(egress?.country);
+    const name = countryName(egress?.country, i18n.language);
+    const addresses = [
+      egress?.ipv4 ? { label: 'v4', value: egress.ipv4 } : null,
+      egress?.ipv6 ? { label: 'v6', value: egress.ipv6 } : null,
+    ].filter((item): item is { label: string; value: string } => Boolean(item));
+
+    if (!egress || (addresses.length === 0 && !egress.country)) {
+      return null;
+    }
+
+    return (
+      <div className="card-egress">
+        <div className="card-egress-row">
+          <span>{t('pages.xray.outbound.egress')}:</span>
+          <Tooltip title={t('pages.index.toggleIpVisibility')}>
+            {isEgressVisible ? (
+              <EyeOutlined className="ip-toggle-icon" role="button" tabIndex={0} aria-label={t('pages.index.toggleIpVisibility')} onClick={() => setCardEgressVisible(rowKey, false)} onKeyDown={activateOnKey(() => setCardEgressVisible(rowKey, false))} />
+            ) : (
+              <EyeInvisibleOutlined className="ip-toggle-icon" role="button" tabIndex={0} aria-label={t('pages.index.toggleIpVisibility')} onClick={() => setCardEgressVisible(rowKey, true)} onKeyDown={activateOnKey(() => setCardEgressVisible(rowKey, true))} />
+            )}
+          </Tooltip>
+          {egress.country && (
+            <CountryPill flag={flag} name={name || egress.country} warp={egress.warp} />
+          )}
+        </div>
+        {addresses.map((addr) => (
+          <Tooltip key={addr.label} title={addr.value}>
+            <div className="card-egress-row">
+              <span className="egress-family">{addr.label}:</span>
+              <span className={isEgressVisible ? 'address-visible egress-ip' : 'address-hidden egress-ip'}>{addr.value}</span>
+            </div>
+          </Tooltip>
+        ))}
+      </div>
+    );
+  };
+
   if (rows.length === 0) {
   if (rows.length === 0) {
     return (
     return (
       <div className="card-empty">
       <div className="card-empty">
@@ -101,6 +156,7 @@ export default function OutboundCardList({
               ))}
               ))}
             </div>
             </div>
           )}
           )}
+          {renderEgress(index, String(record.key))}
           <div className="card-foot">
           <div className="card-foot">
             <span className="traffic-up">↑ {SizeFormatter.sizeFormat(trafficFor(outboundsTraffic, record).up)}</span>
             <span className="traffic-up">↑ {SizeFormatter.sizeFormat(trafficFor(outboundsTraffic, record).up)}</span>
             <span className="traffic-sep" />
             <span className="traffic-sep" />

+ 92 - 0
frontend/src/pages/xray/outbounds/OutboundsTab.css

@@ -75,6 +75,81 @@
   background: var(--ant-color-fill-tertiary);
   background: var(--ant-color-fill-tertiary);
 }
 }
 
 
+.egress-header {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+}
+
+.egress-ip {
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+  font-size: 12px;
+}
+
+.egress-stack {
+  display: inline-flex;
+  flex-direction: column;
+  gap: 3px;
+}
+
+.egress-address {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  min-width: 0;
+}
+
+.egress-family {
+  color: var(--ant-color-text-secondary);
+  font-size: 10px;
+  text-transform: uppercase;
+}
+
+.country-pill {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  border: 1px solid var(--ant-color-border);
+  border-radius: 4px;
+  padding: 1px 6px;
+  line-height: 20px;
+  font-size: 12px;
+  white-space: nowrap;
+}
+
+.country-pill.warp-on {
+  border-color: #f48120;
+}
+
+.warp-cloud-icon {
+  width: 1em;
+  height: 1em;
+  color: #f48120;
+  flex: 0 0 auto;
+}
+
+.warp-cloud-icon svg {
+  fill: #f48120;
+}
+
+.ip-toggle-icon {
+  cursor: pointer;
+  opacity: 0.65;
+}
+
+.ip-toggle-icon:hover {
+  opacity: 1;
+}
+
+.address-hidden {
+  filter: blur(5px);
+  transition: filter 0.2s ease;
+}
+
+.address-visible {
+  filter: none;
+}
+
 .action-cell {
 .action-cell {
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
@@ -110,6 +185,23 @@
   flex-wrap: wrap;
   flex-wrap: wrap;
 }
 }
 
 
+.card-egress {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  gap: 3px;
+  font-size: 12px;
+  opacity: 0.85;
+}
+
+.card-egress-row {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  min-width: 0;
+  max-width: 100%;
+}
+
 .card-test {
 .card-test {
   margin-left: auto;
   margin-left: auto;
   display: inline-flex;
   display: inline-flex;

+ 16 - 0
frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts

@@ -61,6 +61,22 @@ export function trafficFor(outboundsTraffic: OutboundTrafficRow[], o: OutboundRo
   return { up: tr?.up || 0, down: tr?.down || 0 };
   return { up: tr?.up || 0, down: tr?.down || 0 };
 }
 }
 
 
+export function countryFlag(country?: string): string {
+  const code = (country || '').trim().toUpperCase();
+  if (!/^[A-Z]{2}$/.test(code)) return '';
+  return String.fromCodePoint(...[...code].map((ch) => 0x1f1e6 + ch.charCodeAt(0) - 65));
+}
+
+export function countryName(country?: string, locale?: string): string {
+  const code = (country || '').trim().toUpperCase();
+  if (!/^[A-Z]{2}$/.test(code)) return '';
+  try {
+    return new Intl.DisplayNames(locale ? [locale] : undefined, { type: 'region' }).of(code) || code;
+  } catch {
+    return code;
+  }
+}
+
 export function isTesting<K extends string | number>(states: Record<K, OutboundTestState>, idx: K): boolean {
 export function isTesting<K extends string | number>(states: Record<K, OutboundTestState>, idx: K): boolean {
   return !!states?.[idx]?.testing;
   return !!states?.[idx]?.testing;
 }
 }

+ 76 - 3
frontend/src/pages/xray/outbounds/useOutboundColumns.tsx

@@ -1,4 +1,4 @@
-import { useMemo } from 'react';
+import { useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { Button, Dropdown, Tag, Tooltip } from 'antd';
 import { Button, Dropdown, Tag, Tooltip } from 'antd';
 import {
 import {
@@ -11,17 +11,23 @@ import {
   LoadingOutlined,
   LoadingOutlined,
   ArrowUpOutlined,
   ArrowUpOutlined,
   ArrowDownOutlined,
   ArrowDownOutlined,
+  EyeInvisibleOutlined,
+  EyeOutlined,
 } from '@ant-design/icons';
 } from '@ant-design/icons';
 import type { ColumnsType } from 'antd/es/table';
 import type { ColumnsType } from 'antd/es/table';
 
 
 import { SizeFormatter } from '@/utils';
 import { SizeFormatter } from '@/utils';
+import { activateOnKey } from '@/utils/a11y';
 import { OutboundProtocols as Protocols } from '@/schemas/primitives';
 import { OutboundProtocols as Protocols } from '@/schemas/primitives';
 import type { OutboundTestMode, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
 import type { OutboundTestMode, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
 
 
 import type { OutboundRow } from './outbounds-tab-types';
 import type { OutboundRow } from './outbounds-tab-types';
+import CountryPill from './CountryPill';
 import TestResultPopover from './TestResultPopover';
 import TestResultPopover from './TestResultPopover';
 import {
 import {
   effectiveTestMode,
   effectiveTestMode,
+  countryFlag,
+  countryName,
   isTesting,
   isTesting,
   isUntestable,
   isUntestable,
   outboundAddresses,
   outboundAddresses,
@@ -58,7 +64,8 @@ export function useOutboundColumns({
   onResetTraffic,
   onResetTraffic,
   onTest,
   onTest,
 }: OutboundColumnsParams): ColumnsType<OutboundRow> {
 }: OutboundColumnsParams): ColumnsType<OutboundRow> {
-  const { t } = useTranslation();
+  const { t, i18n } = useTranslation();
+  const [showEgressIp, setShowEgressIp] = useState(false);
   return useMemo(
   return useMemo(
     () => [
     () => [
       {
       {
@@ -135,6 +142,72 @@ export function useOutboundColumns({
           );
           );
         },
         },
       },
       },
+      {
+        title: (
+          <span className="egress-header">
+            {t('pages.xray.outbound.egress')}
+            <Tooltip title={t('pages.index.toggleIpVisibility')}>
+              {showEgressIp ? (
+                <EyeOutlined className="ip-toggle-icon" role="button" tabIndex={0} aria-label={t('pages.index.toggleIpVisibility')} onClick={() => setShowEgressIp(false)} onKeyDown={activateOnKey(() => setShowEgressIp(false))} />
+              ) : (
+                <EyeInvisibleOutlined className="ip-toggle-icon" role="button" tabIndex={0} aria-label={t('pages.index.toggleIpVisibility')} onClick={() => setShowEgressIp(true)} onKeyDown={activateOnKey(() => setShowEgressIp(true))} />
+              )}
+            </Tooltip>
+          </span>
+        ),
+        key: 'egress',
+        align: 'left',
+        width: 210,
+        render: (_v, _record, index) => {
+          const egress = testResult(outboundTestStates, index)?.egress;
+          const addresses = [
+            egress?.ipv4 ? { label: 'v4', value: egress.ipv4 } : null,
+            egress?.ipv6 ? { label: 'v6', value: egress.ipv6 } : null,
+          ].filter((item): item is { label: string; value: string } => Boolean(item));
+          if (addresses.length === 0) {
+            return (
+              <Tooltip title={t('pages.xray.outbound.egressHint')}>
+                <span className="empty">—</span>
+              </Tooltip>
+            );
+          }
+          return (
+            <div className="egress-stack">
+              {addresses.map((addr) => (
+                <Tooltip key={addr.label} title={addr.value}>
+                  <span className="egress-address">
+                    <span className="egress-family">{addr.label}</span>
+                    <span className={showEgressIp ? 'address-visible egress-ip' : 'address-hidden egress-ip'}>{addr.value}</span>
+                  </span>
+                </Tooltip>
+              ))}
+            </div>
+          );
+        },
+      },
+      {
+        title: t('pages.xray.outbound.country'),
+        key: 'egressCountry',
+        align: 'left',
+        width: 160,
+        render: (_v, _record, index) => {
+          const egress = testResult(outboundTestStates, index)?.egress;
+          if (!egress?.country) {
+            return (
+              <Tooltip title={t('pages.xray.outbound.egressHint')}>
+                <span className="empty">—</span>
+              </Tooltip>
+            );
+          }
+          const flag = countryFlag(egress.country);
+          const name = countryName(egress.country, i18n.language);
+          return (
+            <Tooltip title={egress.warp ? `Cloudflare trace · WARP ${egress.warp}` : 'Cloudflare trace'}>
+              <CountryPill flag={flag} name={name || egress.country} warp={egress.warp} />
+            </Tooltip>
+          );
+        },
+      },
       {
       {
         title: t('pages.inbounds.traffic'),
         title: t('pages.inbounds.traffic'),
         key: 'traffic',
         key: 'traffic',
@@ -183,6 +256,6 @@ export function useOutboundColumns({
       },
       },
     ],
     ],
     // eslint-disable-next-line react-hooks/exhaustive-deps
     // eslint-disable-next-line react-hooks/exhaustive-deps
-    [t, testMode, rows, outboundTestStates, outboundsTraffic],
+    [t, i18n.language, testMode, rows, outboundTestStates, outboundsTraffic, showEgressIp],
   );
   );
 }
 }

+ 9 - 0
frontend/src/schemas/xray.ts

@@ -78,6 +78,15 @@ export const OutboundTestResultSchema = z.object({
       }).loose(),
       }).loose(),
     )
     )
     .optional(),
     .optional(),
+  egress: z
+    .object({
+      ipv4: z.string().optional(),
+      ipv6: z.string().optional(),
+      country: z.string().optional(),
+      warp: z.string().optional(),
+    })
+    .loose()
+    .optional(),
 }).loose();
 }).loose();
 
 
 // Batch results from /xray/testOutbounds, aligned with the request order.
 // Batch results from /xray/testOutbounds, aligned with the request order.

+ 17 - 0
internal/web/service/outbound/egress_trace_test.go

@@ -0,0 +1,17 @@
+package outbound
+
+import "testing"
+
+func TestParseCloudflareTrace(t *testing.T) {
+	values := parseCloudflareTrace("ip=104.28.1.2\nloc=NL\nwarp=on\n")
+
+	if values["ip"] != "104.28.1.2" {
+		t.Fatalf("ip = %q", values["ip"])
+	}
+	if values["loc"] != "NL" {
+		t.Fatalf("loc = %q", values["loc"])
+	}
+	if values["warp"] != "on" {
+		t.Fatalf("warp = %q", values["warp"])
+	}
+}

+ 10 - 0
internal/web/service/outbound/outbound.go

@@ -142,6 +142,7 @@ type TestOutboundResult struct {
 	TTFBMs     int64 `json:"ttfbMs,omitempty"`
 	TTFBMs     int64 `json:"ttfbMs,omitempty"`
 
 
 	Endpoints []TestEndpointResult `json:"endpoints,omitempty"`
 	Endpoints []TestEndpointResult `json:"endpoints,omitempty"`
+	Egress    *TestEgressResult    `json:"egress,omitempty"`
 }
 }
 
 
 // TestEndpointResult is one entry in a TCP-mode probe — the per-endpoint
 // TestEndpointResult is one entry in a TCP-mode probe — the per-endpoint
@@ -153,6 +154,15 @@ type TestEndpointResult struct {
 	Error   string `json:"error,omitempty"`
 	Error   string `json:"error,omitempty"`
 }
 }
 
 
+// TestEgressResult is populated by HTTP-mode probes from Cloudflare's trace
+// endpoint. It reports what an external service sees after the outbound chain.
+type TestEgressResult struct {
+	IPv4    string `json:"ipv4,omitempty"`
+	IPv6    string `json:"ipv6,omitempty"`
+	Country string `json:"country,omitempty"`
+	Warp    string `json:"warp,omitempty"`
+}
+
 func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundResult, error) {
 func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundResult, error) {
 	var ob map[string]any
 	var ob map[string]any
 	if err := json.Unmarshal([]byte(outboundJSON), &ob); err != nil {
 	if err := json.Unmarshal([]byte(outboundJSON), &ob); err != nil {

+ 153 - 1
internal/web/service/outbound/probe_http.go

@@ -14,6 +14,7 @@ import (
 	"net/url"
 	"net/url"
 	"os"
 	"os"
 	"strconv"
 	"strconv"
+	"strings"
 	"sync"
 	"sync"
 	"time"
 	"time"
 
 
@@ -54,8 +55,13 @@ const (
 	// tcpBatchConcurrency caps parallel TCP-mode items in a batch (each item
 	// tcpBatchConcurrency caps parallel TCP-mode items in a batch (each item
 	// already dials its endpoints concurrently).
 	// already dials its endpoints concurrently).
 	tcpBatchConcurrency = 8
 	tcpBatchConcurrency = 8
+	// egressTraceTimeout keeps diagnostic trace metadata from extending a
+	// successful HTTP probe by the full reachability timeout.
+	egressTraceTimeout = 3 * time.Second
 
 
-	defaultTestURL = "https://www.google.com/generate_204"
+	defaultTestURL  = "https://www.google.com/generate_204"
+	egressTraceHost = "cloudflare.com"
+	egressTracePath = "/cdn-cgi/trace"
 )
 )
 
 
 // httpTestSemaphore serialises HTTP-mode batches (each spawns a temp xray
 // httpTestSemaphore serialises HTTP-mode batches (each spawns a temp xray
@@ -76,6 +82,8 @@ var newBatchProcess = func(cfg *xray.Config, configPath string) batchProcess {
 	return xray.NewTestProcess(cfg, configPath)
 	return xray.NewTestProcess(cfg, configPath)
 }
 }
 
 
+var egressTraceProbe = probeEgressTrace
+
 // httpBatchItem is one outbound inside an HTTP-mode batch. result is the
 // httpBatchItem is one outbound inside an HTTP-mode batch. result is the
 // pre-allocated entry in the caller's result slice, filled in place.
 // pre-allocated entry in the caller's result slice, filled in place.
 type httpBatchItem struct {
 type httpBatchItem struct {
@@ -542,6 +550,150 @@ func probeThroughSocks(port int, testURL string, timeout time.Duration, realDela
 		}
 		}
 	}
 	}
 	result.Delay = max(delay, 1)
 	result.Delay = max(delay, 1)
+	if !realDelay {
+		result.Egress = egressTraceProbe(proxyURL)
+	}
+}
+
+// probeEgressTrace fetches Cloudflare's plain-text trace endpoint through the
+// same SOCKS route used by the HTTP probe. It asks one IPv4 and one IPv6
+// Cloudflare address directly, when available, while keeping the TLS SNI as
+// cloudflare.com. Failures are intentionally ignored by the caller: egress
+// metadata is diagnostic, not reachability.
+func probeEgressTrace(proxyURL *url.URL) *TestEgressResult {
+	ipv4, ipv6 := cloudflareTraceTargets()
+	if ipv4 == nil && ipv6 == nil {
+		return nil
+	}
+
+	tr := &http.Transport{
+		Proxy: http.ProxyURL(proxyURL),
+		TLSClientConfig: &tls.Config{
+			ServerName: egressTraceHost,
+		},
+	}
+	defer tr.CloseIdleConnections()
+
+	client := &http.Client{
+		Transport: tr,
+		Timeout:   egressTraceTimeout,
+	}
+
+	egress := &TestEgressResult{}
+	targets := make([]net.IP, 0, 2)
+	if ipv4 != nil {
+		targets = append(targets, ipv4)
+	}
+	if ipv6 != nil {
+		targets = append(targets, ipv6)
+	}
+	results := make(chan map[string]string, len(targets))
+	for _, target := range targets {
+		go func(ip net.IP) {
+			results <- fetchCloudflareTrace(client, ip)
+		}(target)
+	}
+	for range targets {
+		applyEgressTrace(egress, <-results)
+	}
+	if egress.IPv4 == "" && egress.IPv6 == "" && egress.Country == "" && egress.Warp == "" {
+		return nil
+	}
+
+	return egress
+}
+
+func cloudflareTraceTargets() (net.IP, net.IP) {
+	ctx, cancel := context.WithTimeout(context.Background(), egressTraceTimeout)
+	defer cancel()
+
+	addrs, err := net.DefaultResolver.LookupIPAddr(ctx, egressTraceHost)
+	if err != nil {
+		return nil, nil
+	}
+	var ipv4, ipv6 net.IP
+	for _, addr := range addrs {
+		ip := addr.IP
+		if ipv4 == nil {
+			if v4 := ip.To4(); v4 != nil {
+				ipv4 = v4
+				continue
+			}
+		}
+		if ipv6 == nil && ip.To4() == nil && ip.To16() != nil {
+			ipv6 = ip
+		}
+		if ipv4 != nil && ipv6 != nil {
+			break
+		}
+	}
+	return ipv4, ipv6
+}
+
+func fetchCloudflareTrace(client *http.Client, ip net.IP) map[string]string {
+	if ip == nil {
+		return nil
+	}
+	traceURL := (&url.URL{
+		Scheme: "https",
+		Host:   net.JoinHostPort(ip.String(), "443"),
+		Path:   egressTracePath,
+	}).String()
+	req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, traceURL, nil)
+	if err != nil {
+		return nil
+	}
+	req.Host = egressTraceHost
+	resp, err := client.Do(req)
+	if err != nil {
+		return nil
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+		return nil
+	}
+	body, err := io.ReadAll(io.LimitReader(resp.Body, 16<<10))
+	if err != nil {
+		return nil
+	}
+	return parseCloudflareTrace(string(body))
+}
+
+func applyEgressTrace(egress *TestEgressResult, values map[string]string) {
+	if len(values) == 0 {
+		return
+	}
+	if ip := net.ParseIP(values["ip"]); ip != nil {
+		if ip.To4() != nil {
+			if egress.IPv4 == "" {
+				egress.IPv4 = values["ip"]
+			}
+		} else if egress.IPv6 == "" {
+			egress.IPv6 = values["ip"]
+		}
+	}
+	if egress.Country == "" {
+		egress.Country = values["loc"]
+	}
+	if values["warp"] == "on" || egress.Warp == "" {
+		egress.Warp = values["warp"]
+	}
+}
+
+func parseCloudflareTrace(body string) map[string]string {
+	values := make(map[string]string)
+	for line := range strings.SplitSeq(body, "\n") {
+		line = strings.TrimSpace(line)
+		if line == "" {
+			continue
+		}
+		key, value, ok := strings.Cut(line, "=")
+		if !ok {
+			continue
+		}
+		values[strings.TrimSpace(key)] = strings.TrimSpace(value)
+	}
+	return values
 }
 }
 
 
 // timedWarmGet re-issues the probe request over the transport's kept-alive
 // timedWarmGet re-issues the probe request over the transport's kept-alive

+ 20 - 0
internal/web/service/outbound/probe_http_test.go

@@ -9,6 +9,7 @@ import (
 	"net"
 	"net"
 	"net/http"
 	"net/http"
 	"net/http/httptest"
 	"net/http/httptest"
+	"net/url"
 	"strconv"
 	"strconv"
 	"strings"
 	"strings"
 	"sync"
 	"sync"
@@ -135,6 +136,13 @@ func withStubProcess(t *testing.T, factory func(cfg *xray.Config, configPath str
 	t.Cleanup(func() { newBatchProcess = orig })
 	t.Cleanup(func() { newBatchProcess = orig })
 }
 }
 
 
+func withEgressTraceProbe(t *testing.T, probe func(*url.URL) *TestEgressResult) {
+	t.Helper()
+	orig := egressTraceProbe
+	egressTraceProbe = probe
+	t.Cleanup(func() { egressTraceProbe = orig })
+}
+
 func mustJSON(t *testing.T, v any) string {
 func mustJSON(t *testing.T, v any) string {
 	t.Helper()
 	t.Helper()
 	b, err := json.Marshal(v)
 	b, err := json.Marshal(v)
@@ -417,6 +425,9 @@ func TestTestOutboundsHTTPBatchThroughStubSocks(t *testing.T) {
 		proc = &stubProcess{cfg: cfg, serveSocks: true}
 		proc = &stubProcess{cfg: cfg, serveSocks: true}
 		return proc
 		return proc
 	})
 	})
+	withEgressTraceProbe(t, func(*url.URL) *TestEgressResult {
+		return &TestEgressResult{IPv4: "198.51.100.1", Country: "ZZ", Warp: "off"}
+	})
 
 
 	batch := mustJSON(t, []any{
 	batch := mustJSON(t, []any{
 		map[string]any{"tag": "a", "protocol": "vless"},
 		map[string]any{"tag": "a", "protocol": "vless"},
@@ -445,6 +456,9 @@ func TestTestOutboundsHTTPBatchThroughStubSocks(t *testing.T) {
 		if r.Mode != "http" {
 		if r.Mode != "http" {
 			t.Errorf("result %d mode = %q", i, r.Mode)
 			t.Errorf("result %d mode = %q", i, r.Mode)
 		}
 		}
+		if r.Egress == nil || r.Egress.IPv4 != "198.51.100.1" {
+			t.Errorf("result %d egress = %+v", i, r.Egress)
+		}
 	}
 	}
 	if proc.IsRunning() {
 	if proc.IsRunning() {
 		t.Error("temp process not stopped after batch")
 		t.Error("temp process not stopped after batch")
@@ -525,6 +539,9 @@ func TestTestOutboundsTCPModeForcesUDPToHTTPProbe(t *testing.T) {
 	withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess {
 	withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess {
 		return &stubProcess{cfg: cfg, serveSocks: true}
 		return &stubProcess{cfg: cfg, serveSocks: true}
 	})
 	})
+	withEgressTraceProbe(t, func(*url.URL) *TestEgressResult {
+		return &TestEgressResult{IPv4: "198.51.100.2", Country: "ZZ", Warp: "off"}
+	})
 
 
 	batch := mustJSON(t, []any{map[string]any{"tag": "wg", "protocol": "wireguard"}})
 	batch := mustJSON(t, []any{map[string]any{"tag": "wg", "protocol": "wireguard"}})
 	results, err := (&OutboundService{}).TestOutbounds(batch, srv.URL, "", "tcp")
 	results, err := (&OutboundService{}).TestOutbounds(batch, srv.URL, "", "tcp")
@@ -535,6 +552,9 @@ func TestTestOutboundsTCPModeForcesUDPToHTTPProbe(t *testing.T) {
 	if !r.Success || r.Mode != "http" {
 	if !r.Success || r.Mode != "http" {
 		t.Errorf("UDP outbound in tcp mode = %+v, want success with mode %q", r, "http")
 		t.Errorf("UDP outbound in tcp mode = %+v, want success with mode %q", r, "http")
 	}
 	}
+	if r.Egress == nil || r.Egress.IPv4 != "198.51.100.2" {
+		t.Errorf("UDP outbound egress = %+v", r.Egress)
+	}
 }
 }
 
 
 func TestProbeModeLabel(t *testing.T) {
 func TestProbeModeLabel(t *testing.T) {

+ 2 - 0
internal/web/translation/ar-EG.json

@@ -1622,6 +1622,8 @@
         "tag": "الوسم",
         "tag": "الوسم",
         "tagDesc": "تاج فريد",
         "tagDesc": "تاج فريد",
         "address": "العنوان",
         "address": "العنوان",
+        "egress": "Egress",
+        "egressHint": "Run an HTTP test to show egress IP and country.",
         "reverse": "عكسي",
         "reverse": "عكسي",
         "domain": "النطاق",
         "domain": "النطاق",
         "type": "النوع",
         "type": "النوع",

+ 2 - 0
internal/web/translation/en-US.json

@@ -1739,6 +1739,8 @@
         "tag": "Tag",
         "tag": "Tag",
         "tagDesc": "Unique Tag",
         "tagDesc": "Unique Tag",
         "address": "Address",
         "address": "Address",
+        "egress": "Egress",
+        "egressHint": "Run an HTTP test to show egress IP and country.",
         "reverse": "Reverse",
         "reverse": "Reverse",
         "domain": "Domain",
         "domain": "Domain",
         "type": "Type",
         "type": "Type",

+ 2 - 0
internal/web/translation/es-ES.json

@@ -1622,6 +1622,8 @@
         "tag": "Etiqueta",
         "tag": "Etiqueta",
         "tagDesc": "etiqueta única",
         "tagDesc": "etiqueta única",
         "address": "Dirección",
         "address": "Dirección",
+        "egress": "Egress",
+        "egressHint": "Run an HTTP test to show egress IP and country.",
         "reverse": "Reverso",
         "reverse": "Reverso",
         "domain": "Dominio",
         "domain": "Dominio",
         "type": "Tipo",
         "type": "Tipo",

+ 2 - 0
internal/web/translation/fa-IR.json

@@ -1622,6 +1622,8 @@
         "tag": "تگ",
         "tag": "تگ",
         "tagDesc": "برچسب یگانه",
         "tagDesc": "برچسب یگانه",
         "address": "آدرس",
         "address": "آدرس",
+        "egress": "Egress",
+        "egressHint": "Run an HTTP test to show egress IP and country.",
         "reverse": "معکوس",
         "reverse": "معکوس",
         "domain": "دامنه",
         "domain": "دامنه",
         "type": "نوع",
         "type": "نوع",

+ 2 - 0
internal/web/translation/id-ID.json

@@ -1622,6 +1622,8 @@
         "tag": "Tag",
         "tag": "Tag",
         "tagDesc": "Tag Unik",
         "tagDesc": "Tag Unik",
         "address": "Alamat",
         "address": "Alamat",
+        "egress": "Egress",
+        "egressHint": "Run an HTTP test to show egress IP and country.",
         "reverse": "Revers",
         "reverse": "Revers",
         "domain": "Domain",
         "domain": "Domain",
         "type": "Tipe",
         "type": "Tipe",

+ 2 - 0
internal/web/translation/ja-JP.json

@@ -1622,6 +1622,8 @@
         "tag": "タグ",
         "tag": "タグ",
         "tagDesc": "一意のタグ",
         "tagDesc": "一意のタグ",
         "address": "アドレス",
         "address": "アドレス",
+        "egress": "Egress",
+        "egressHint": "Run an HTTP test to show egress IP and country.",
         "reverse": "リバース",
         "reverse": "リバース",
         "domain": "ドメイン",
         "domain": "ドメイン",
         "type": "タイプ",
         "type": "タイプ",

+ 2 - 0
internal/web/translation/pt-BR.json

@@ -1622,6 +1622,8 @@
         "tag": "Tag",
         "tag": "Tag",
         "tagDesc": "Tag Única",
         "tagDesc": "Tag Única",
         "address": "Endereço",
         "address": "Endereço",
+        "egress": "Egress",
+        "egressHint": "Run an HTTP test to show egress IP and country.",
         "reverse": "Reverso",
         "reverse": "Reverso",
         "domain": "Domínio",
         "domain": "Domínio",
         "type": "Tipo",
         "type": "Tipo",

+ 2 - 0
internal/web/translation/ru-RU.json

@@ -1622,6 +1622,8 @@
         "tag": "Тег",
         "tag": "Тег",
         "tagDesc": "Уникальный тег",
         "tagDesc": "Уникальный тег",
         "address": "Адрес",
         "address": "Адрес",
+        "egress": "Выход",
+        "egressHint": "Запустите HTTP-тест, чтобы показать выходной IP и страну.",
         "reverse": "Реверс-прокси",
         "reverse": "Реверс-прокси",
         "domain": "Домен",
         "domain": "Домен",
         "type": "Тип",
         "type": "Тип",

+ 2 - 0
internal/web/translation/tr-TR.json

@@ -1622,6 +1622,8 @@
         "tag": "Etiket",
         "tag": "Etiket",
         "tagDesc": "Benzersiz Etiket",
         "tagDesc": "Benzersiz Etiket",
         "address": "Adres",
         "address": "Adres",
+        "egress": "Egress",
+        "egressHint": "Run an HTTP test to show egress IP and country.",
         "reverse": "Ters",
         "reverse": "Ters",
         "domain": "Alan Adı",
         "domain": "Alan Adı",
         "type": "Tür",
         "type": "Tür",

+ 2 - 0
internal/web/translation/uk-UA.json

@@ -1622,6 +1622,8 @@
         "tag": "Тег",
         "tag": "Тег",
         "tagDesc": "Унікальний тег",
         "tagDesc": "Унікальний тег",
         "address": "Адреса",
         "address": "Адреса",
+        "egress": "Egress",
+        "egressHint": "Run an HTTP test to show egress IP and country.",
         "reverse": "Зворотний",
         "reverse": "Зворотний",
         "domain": "Домен",
         "domain": "Домен",
         "type": "Тип",
         "type": "Тип",

+ 2 - 0
internal/web/translation/vi-VN.json

@@ -1622,6 +1622,8 @@
         "tag": "Tag",
         "tag": "Tag",
         "tagDesc": "thẻ duy nhất",
         "tagDesc": "thẻ duy nhất",
         "address": "Địa chỉ",
         "address": "Địa chỉ",
+        "egress": "Egress",
+        "egressHint": "Run an HTTP test to show egress IP and country.",
         "reverse": "Đảo ngược",
         "reverse": "Đảo ngược",
         "domain": "Tên miền",
         "domain": "Tên miền",
         "type": "Loại",
         "type": "Loại",

+ 2 - 0
internal/web/translation/zh-CN.json

@@ -1622,6 +1622,8 @@
         "tag": "标签",
         "tag": "标签",
         "tagDesc": "唯一标签",
         "tagDesc": "唯一标签",
         "address": "地址",
         "address": "地址",
+        "egress": "Egress",
+        "egressHint": "Run an HTTP test to show egress IP and country.",
         "reverse": "反向",
         "reverse": "反向",
         "domain": "域名",
         "domain": "域名",
         "type": "类型",
         "type": "类型",

+ 2 - 0
internal/web/translation/zh-TW.json

@@ -1622,6 +1622,8 @@
         "tag": "標籤",
         "tag": "標籤",
         "tagDesc": "唯一標籤",
         "tagDesc": "唯一標籤",
         "address": "地址",
         "address": "地址",
+        "egress": "Egress",
+        "egressHint": "Run an HTTP test to show egress IP and country.",
         "reverse": "反向",
         "reverse": "反向",
         "domain": "網域",
         "domain": "網域",
         "type": "類型",
         "type": "類型",