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.
This commit is contained in:
isultanov99
2026-07-12 15:09:52 +02:00
committed by GitHub
parent 2c95e29297
commit 30f6bc1833
23 changed files with 494 additions and 5 deletions
@@ -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>
);
}
@@ -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" />
@@ -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;
@@ -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;
} }
@@ -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
View File
@@ -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.
@@ -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
View File
@@ -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
View File
@@ -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
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -1622,6 +1622,8 @@
"tag": "Тег", "tag": "Тег",
"tagDesc": "Уникальный тег", "tagDesc": "Уникальный тег",
"address": "Адрес", "address": "Адрес",
"egress": "Выход",
"egressHint": "Запустите HTTP-тест, чтобы показать выходной IP и страну.",
"reverse": "Реверс-прокси", "reverse": "Реверс-прокси",
"domain": "Домен", "domain": "Домен",
"type": "Тип", "type": "Тип",
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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": "類型",