Просмотр исходного кода

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 часов назад
Родитель
Сommit
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 { Button, Dropdown, Tag, Tooltip } from 'antd';
 import {
@@ -9,15 +10,21 @@ import {
   ThunderboltOutlined,
   LoadingOutlined,
   ExportOutlined,
+  EyeInvisibleOutlined,
+  EyeOutlined,
 } from '@ant-design/icons';
 
 import { SizeFormatter } from '@/utils';
+import { activateOnKey } from '@/utils/a11y';
 import { OutboundProtocols as Protocols } from '@/schemas/primitives';
 import type { OutboundTestMode, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
 
 import type { OutboundRow } from './outbounds-tab-types';
+import CountryPill from './CountryPill';
 import TestResultPopover from './TestResultPopover';
 import {
+  countryFlag,
+  countryName,
   isTesting,
   isUntestable,
   outboundAddresses,
@@ -49,7 +56,55 @@ export default function OutboundCardList({
   confirmDelete,
   onTest,
 }: 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) {
     return (
       <div className="card-empty">
@@ -101,6 +156,7 @@ export default function OutboundCardList({
               ))}
             </div>
           )}
+          {renderEgress(index, String(record.key))}
           <div className="card-foot">
             <span className="traffic-up">↑ {SizeFormatter.sizeFormat(trafficFor(outboundsTraffic, record).up)}</span>
             <span className="traffic-sep" />

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

@@ -75,6 +75,81 @@
   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 {
   display: flex;
   align-items: center;
@@ -110,6 +185,23 @@
   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 {
   margin-left: auto;
   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 };
 }
 
+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 {
   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 { Button, Dropdown, Tag, Tooltip } from 'antd';
 import {
@@ -11,17 +11,23 @@ import {
   LoadingOutlined,
   ArrowUpOutlined,
   ArrowDownOutlined,
+  EyeInvisibleOutlined,
+  EyeOutlined,
 } from '@ant-design/icons';
 import type { ColumnsType } from 'antd/es/table';
 
 import { SizeFormatter } from '@/utils';
+import { activateOnKey } from '@/utils/a11y';
 import { OutboundProtocols as Protocols } from '@/schemas/primitives';
 import type { OutboundTestMode, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
 
 import type { OutboundRow } from './outbounds-tab-types';
+import CountryPill from './CountryPill';
 import TestResultPopover from './TestResultPopover';
 import {
   effectiveTestMode,
+  countryFlag,
+  countryName,
   isTesting,
   isUntestable,
   outboundAddresses,
@@ -58,7 +64,8 @@ export function useOutboundColumns({
   onResetTraffic,
   onTest,
 }: OutboundColumnsParams): ColumnsType<OutboundRow> {
-  const { t } = useTranslation();
+  const { t, i18n } = useTranslation();
+  const [showEgressIp, setShowEgressIp] = useState(false);
   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'),
         key: 'traffic',
@@ -183,6 +256,6 @@ export function useOutboundColumns({
       },
     ],
     // 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(),
     )
     .optional(),
+  egress: z
+    .object({
+      ipv4: z.string().optional(),
+      ipv6: z.string().optional(),
+      country: z.string().optional(),
+      warp: z.string().optional(),
+    })
+    .loose()
+    .optional(),
 }).loose();
 
 // 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"`
 
 	Endpoints []TestEndpointResult `json:"endpoints,omitempty"`
+	Egress    *TestEgressResult    `json:"egress,omitempty"`
 }
 
 // TestEndpointResult is one entry in a TCP-mode probe — the per-endpoint
@@ -153,6 +154,15 @@ type TestEndpointResult struct {
 	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) {
 	var ob map[string]any
 	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"
 	"os"
 	"strconv"
+	"strings"
 	"sync"
 	"time"
 
@@ -54,8 +55,13 @@ const (
 	// tcpBatchConcurrency caps parallel TCP-mode items in a batch (each item
 	// already dials its endpoints concurrently).
 	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
@@ -76,6 +82,8 @@ var newBatchProcess = func(cfg *xray.Config, configPath string) batchProcess {
 	return xray.NewTestProcess(cfg, configPath)
 }
 
+var egressTraceProbe = probeEgressTrace
+
 // 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 {
@@ -542,6 +550,150 @@ func probeThroughSocks(port int, testURL string, timeout time.Duration, realDela
 		}
 	}
 	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

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

@@ -9,6 +9,7 @@ import (
 	"net"
 	"net/http"
 	"net/http/httptest"
+	"net/url"
 	"strconv"
 	"strings"
 	"sync"
@@ -135,6 +136,13 @@ func withStubProcess(t *testing.T, factory func(cfg *xray.Config, configPath str
 	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 {
 	t.Helper()
 	b, err := json.Marshal(v)
@@ -417,6 +425,9 @@ func TestTestOutboundsHTTPBatchThroughStubSocks(t *testing.T) {
 		proc = &stubProcess{cfg: cfg, serveSocks: true}
 		return proc
 	})
+	withEgressTraceProbe(t, func(*url.URL) *TestEgressResult {
+		return &TestEgressResult{IPv4: "198.51.100.1", Country: "ZZ", Warp: "off"}
+	})
 
 	batch := mustJSON(t, []any{
 		map[string]any{"tag": "a", "protocol": "vless"},
@@ -445,6 +456,9 @@ func TestTestOutboundsHTTPBatchThroughStubSocks(t *testing.T) {
 		if r.Mode != "http" {
 			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() {
 		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 {
 		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"}})
 	results, err := (&OutboundService{}).TestOutbounds(batch, srv.URL, "", "tcp")
@@ -535,6 +552,9 @@ func TestTestOutboundsTCPModeForcesUDPToHTTPProbe(t *testing.T) {
 	if !r.Success || r.Mode != "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) {

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

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

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

@@ -1739,6 +1739,8 @@
         "tag": "Tag",
         "tagDesc": "Unique Tag",
         "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/es-ES.json

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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