fix(xray): test UDP outbounds via xray probe (#4657) + Vision testseed & Flow form fixes

Outbound connection tester (#4657): UDP-based outbounds (wireguard,
hysteria, kcp/quic transports) were probed with a raw UDP dial that
treated the inevitable read timeout as success, so every one reported a
fake ~5s 'alive'. Route them through the authoritative xray
burstObservatory probe and drop the broken raw-UDP path. Test All now
runs a parallel TCP lane and a serial HTTP lane so xray-probe outbounds
don't collide on the test semaphore.

Vision testseed: the [900, 500, 900, 256] default repeats 900, and a
tags Select keys each tag by value -> 'two children with the same key,
900'. Render it as four InputNumbers (inbound + outbound forms); the
field is a fixed 4-tuple where repeats are valid.

Inbound form: drop the null-valued 'Local Panel' Select option (AntD
rejects null option values; placeholder + allowClear already cover it).

Outbound form: add an explicit 'None' option to the Flow selector.
This commit is contained in:
MHSanaei 2026-05-29 21:07:01 +02:00
parent 8c30ddbfd9
commit cb7af04cd3
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
5 changed files with 88 additions and 133 deletions

View file

@ -17,6 +17,13 @@ import {
const DIRTY_POLL_MS = 1000; const DIRTY_POLL_MS = 1000;
const DEFAULT_TEST_URL = 'https://www.google.com/generate_204'; const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
export function isUdpOutbound(outbound: unknown): boolean {
const o = outbound as { protocol?: string; streamSettings?: { network?: string } } | null | undefined;
const p = o?.protocol;
const n = o?.streamSettings?.network;
return p === 'wireguard' || p === 'hysteria' || n === 'hysteria' || n === 'kcp' || n === 'quic';
}
export type { OutboundTrafficRow, OutboundTestResult }; export type { OutboundTrafficRow, OutboundTestResult };
export type XraySettingsValue = z.infer<typeof XraySettingsValueSchema>; export type XraySettingsValue = z.infer<typeof XraySettingsValueSchema>;
@ -243,15 +250,16 @@ export function useXraySetting(): UseXraySettingResult {
const testOutbound = useCallback( const testOutbound = useCallback(
async (index: number, outbound: unknown, mode = 'tcp'): Promise<OutboundTestResult | null> => { async (index: number, outbound: unknown, mode = 'tcp'): Promise<OutboundTestResult | null> => {
if (!outbound) return null; if (!outbound) return null;
const effMode = isUdpOutbound(outbound) ? 'http' : mode;
setOutboundTestStates((prev) => ({ setOutboundTestStates((prev) => ({
...prev, ...prev,
[index]: { testing: true, result: null, mode }, [index]: { testing: true, result: null, mode: effMode },
})); }));
try { try {
const raw = await HttpUtil.post('/panel/xray/testOutbound', { const raw = await HttpUtil.post('/panel/xray/testOutbound', {
outbound: JSON.stringify(outbound), outbound: JSON.stringify(outbound),
allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []), allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []),
mode, mode: effMode,
}); });
const msg = parseMsg(raw, OutboundTestResultSchema, 'xray/testOutbound'); const msg = parseMsg(raw, OutboundTestResultSchema, 'xray/testOutbound');
if (msg?.success && msg.obj) { if (msg?.success && msg.obj) {
@ -265,7 +273,7 @@ export function useXraySetting(): UseXraySettingResult {
...prev, ...prev,
[index]: { [index]: {
testing: false, testing: false,
result: { success: false, error: msg?.msg || 'Unknown error', mode }, result: { success: false, error: msg?.msg || 'Unknown error', mode: effMode },
}, },
})); }));
} catch (e) { } catch (e) {
@ -273,7 +281,7 @@ export function useXraySetting(): UseXraySettingResult {
...prev, ...prev,
[index]: { [index]: {
testing: false, testing: false,
result: { success: false, error: String(e), mode }, result: { success: false, error: String(e), mode: effMode },
}, },
})); }));
} }
@ -287,28 +295,31 @@ export function useXraySetting(): UseXraySettingResult {
if (list.length === 0 || testingAll) return; if (list.length === 0 || testingAll) return;
setTestingAll(true); setTestingAll(true);
try { try {
const concurrency = mode === 'tcp' ? 8 : 1; const tcpQueue: { index: number; outbound: unknown }[] = [];
const queue = list const httpQueue: { index: number; outbound: unknown }[] = [];
.map((ob, i) => ({ index: i, outbound: ob })) list.forEach((ob, i) => {
.filter(({ outbound }) => { const tag = ob?.tag;
const tag = outbound?.tag; const proto = ob?.protocol;
const proto = outbound?.protocol; if (proto === 'blackhole' || proto === 'loopback' || tag === 'blocked') return;
if (proto === 'blackhole' || proto === 'loopback' || tag === 'blocked') return false; if (mode === 'tcp' && (proto === 'freedom' || proto === 'dns')) return;
if (mode === 'tcp' && (proto === 'freedom' || proto === 'dns')) return false; if (mode === 'http' || isUdpOutbound(ob)) {
return true; httpQueue.push({ index: i, outbound: ob });
}); } else {
async function worker() { tcpQueue.push({ index: i, outbound: ob });
while (queue.length > 0) {
const item = queue.shift();
if (!item) break;
await testOutbound(item.index, item.outbound, mode);
} }
} });
const workers = Array.from( const runLane = async (queue: { index: number; outbound: unknown }[], concurrency: number) => {
{ length: Math.min(concurrency, queue.length) }, const worker = async () => {
() => worker(), while (queue.length > 0) {
); const item = queue.shift();
await Promise.all(workers); if (!item) break;
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([runLane(tcpQueue, 8), runLane(httpQueue, 1)]);
} finally { } finally {
setTestingAll(false); setTestingAll(false);
} }

View file

@ -931,14 +931,11 @@ export default function InboundFormModal({
disabled={mode === 'edit'} disabled={mode === 'edit'}
placeholder={t('pages.inbounds.localPanel')} placeholder={t('pages.inbounds.localPanel')}
allowClear allowClear
options={[ options={selectableNodes.map((n) => ({
{ value: null, label: t('pages.inbounds.localPanel') }, value: n.id,
...selectableNodes.map((n) => ({ label: `${n.name}${n.status === 'offline' ? ' (offline)' : ''}`,
value: n.id, disabled: n.status === 'offline',
label: `${n.name}${n.status === 'offline' ? ' (offline)' : ''}`, }))}
disabled: n.status === 'offline',
})),
]}
/> />
</Form.Item> </Form.Item>
)} )}
@ -1498,16 +1495,15 @@ export default function InboundFormModal({
{network === 'tcp' && (security === 'tls' || security === 'reality') && ( {network === 'tcp' && (security === 'tls' || security === 'reality') && (
<Form.Item <Form.Item
label={t('pages.inbounds.form.visionTestseed')} label={t('pages.inbounds.form.visionTestseed')}
name={['settings', 'testseed']}
initialValue={[900, 500, 900, 256]}
normalize={(v: unknown) =>
Array.isArray(v)
? v.map((x) => Number(x)).filter((n) => Number.isInteger(n) && n > 0)
: []
}
extra="Applies only to clients using the xtls-rprx-vision flow; ignored otherwise." extra="Applies only to clients using the xtls-rprx-vision flow; ignored otherwise."
> >
<Select mode="tags" tokenSeparators={[',', ' ']} placeholder="four positive integers" /> <Space.Compact block>
{[900, 500, 900, 256].map((def, i) => (
<Form.Item key={i} name={['settings', 'testseed', i]} noStyle initialValue={def}>
<InputNumber min={1} style={{ width: '25%' }} />
</Form.Item>
))}
</Space.Compact>
</Form.Item> </Form.Item>
)} )}
</> </>

View file

@ -191,8 +191,8 @@ export default function OutboundFormModal({
const [linkInput, setLinkInput] = useState(''); const [linkInput, setLinkInput] = useState('');
// Parse a share link (vmess:// / vless:// / trojan:// / ss:// / // Parse a share link (vmess:// / vless:// / trojan:// / ss:// /
// hysteria2://) and replace form state with the result. The current // hysteria2:// / wireguard://) and replace form state with the result.
// tag is preserved when the parsed link doesn't carry one. // The current tag is preserved when the parsed link doesn't carry one.
function importLink() { function importLink() {
const link = linkInput.trim(); const link = linkInput.trim();
if (!link) return; if (!link) return;
@ -1743,7 +1743,7 @@ export default function OutboundFormModal({
<Select <Select
allowClear allowClear
placeholder={t('none')} placeholder={t('none')}
options={FLOW_OPTIONS} options={[{ value: '', label: t('none') }, ...FLOW_OPTIONS]}
/> />
</Form.Item> </Form.Item>
)} )}
@ -1762,22 +1762,14 @@ export default function OutboundFormModal({
<Form.Item label={t('pages.xray.outboundForm.visionTestpre')} name={['settings', 'testpre']}> <Form.Item label={t('pages.xray.outboundForm.visionTestpre')} name={['settings', 'testpre']}>
<InputNumber min={0} style={{ width: '100%' }} /> <InputNumber min={0} style={{ width: '100%' }} />
</Form.Item> </Form.Item>
<Form.Item <Form.Item label={t('pages.inbounds.form.visionTestseed')}>
label={t('pages.inbounds.form.visionTestseed')} <Space.Compact block>
name={['settings', 'testseed']} {[900, 500, 900, 256].map((def, i) => (
normalize={(v: unknown) => <Form.Item key={i} name={['settings', 'testseed', i]} noStyle initialValue={def}>
Array.isArray(v) <InputNumber min={1} style={{ width: '25%' }} />
? v </Form.Item>
.map((x) => Number(x)) ))}
.filter((n) => Number.isInteger(n) && n > 0) </Space.Compact>
: []
}
>
<Select
mode="tags"
tokenSeparators={[',', ' ']}
placeholder="four positive integers"
/>
</Form.Item> </Form.Item>
</> </>
); );
@ -2215,7 +2207,7 @@ export default function OutboundFormModal({
<Space orientation="vertical" size={10} style={{ width: '100%', marginTop: 10 }}> <Space orientation="vertical" size={10} style={{ width: '100%', marginTop: 10 }}>
<Input.Search <Input.Search
value={linkInput} value={linkInput}
placeholder="vmess:// vless:// trojan:// ss:// hysteria2://" placeholder="vmess:// vless:// trojan:// ss:// hysteria2:// wireguard://"
enterButton="Import" enterButton="Import"
onChange={(e) => setLinkInput(e.target.value)} onChange={(e) => setLinkInput(e.target.value)}
onSearch={importLink} onSearch={importLink}

View file

@ -36,6 +36,7 @@ import type { ColumnsType } from 'antd/es/table';
import { SizeFormatter } from '@/utils'; import { SizeFormatter } from '@/utils';
import { OutboundProtocols as Protocols } from '@/schemas/primitives'; import { OutboundProtocols as Protocols } from '@/schemas/primitives';
import OutboundFormModal from './OutboundFormModal'; import OutboundFormModal from './OutboundFormModal';
import { isUdpOutbound } from '@/hooks/useXraySetting';
import type { XraySettingsValue, SetTemplate, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting'; import type { XraySettingsValue, SetTemplate, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
import './OutboundsTab.css'; import './OutboundsTab.css';
@ -361,7 +362,7 @@ export default function OutboundsTab({
align: 'center', align: 'center',
width: 80, width: 80,
render: (_v, record, index) => ( render: (_v, record, index) => (
<Tooltip title={`${t('check')} (${testMode.toUpperCase()})`}> <Tooltip title={`${t('check')} (${(isUdpOutbound(record) ? 'http' : testMode).toUpperCase()})`}>
<Button <Button
type="primary" type="primary"
shape="circle" shape="circle"

View file

@ -151,6 +151,14 @@ type TestEndpointResult struct {
// sockopt.dialerProxy chains during test). // sockopt.dialerProxy chains during test).
func (s *OutboundService) TestOutbound(outboundJSON string, testURL string, allOutboundsJSON string, mode string) (*TestOutboundResult, error) { func (s *OutboundService) TestOutbound(outboundJSON string, testURL string, allOutboundsJSON string, mode string) (*TestOutboundResult, error) {
if mode == "tcp" { 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.testOutboundTCP(outboundJSON)
} }
return s.testOutboundHTTP(outboundJSON, testURL, allOutboundsJSON) return s.testOutboundHTTP(outboundJSON, testURL, allOutboundsJSON)
@ -178,7 +186,7 @@ func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundRes
wg.Add(1) wg.Add(1)
go func(i int) { go func(i int) {
defer wg.Done() defer wg.Done()
results[i] = probeEndpoint(endpoints[i], 5*time.Second) results[i] = probeTCPEndpoint(endpoints[i], 5*time.Second)
}(i) }(i)
} }
wg.Wait() wg.Wait()
@ -195,11 +203,7 @@ func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundRes
} }
} }
mode := "tcp" out := &TestOutboundResult{Mode: "tcp", Endpoints: results}
if endpoints[0].Network == "udp" {
mode = "udp"
}
out := &TestOutboundResult{Mode: mode, Endpoints: results}
if bestDelay >= 0 { if bestDelay >= 0 {
out.Success = true out.Success = true
out.Delay = bestDelay out.Delay = bestDelay
@ -212,22 +216,6 @@ func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundRes
return out, nil return out, nil
} }
// outboundEndpoint is a host:port plus the transport its proxy actually
// listens on. WireGuard (and WARP, which is WireGuard) is UDP-only, so a
// TCP dial to its peer endpoint always times out — the probe must match
// the transport of the outbound being tested.
type outboundEndpoint struct {
Address string
Network string
}
func probeEndpoint(ep outboundEndpoint, timeout time.Duration) TestEndpointResult {
if ep.Network == "udp" {
return probeUDPEndpoint(ep.Address, timeout)
}
return probeTCPEndpoint(ep.Address, timeout)
}
func probeTCPEndpoint(endpoint string, timeout time.Duration) TestEndpointResult { func probeTCPEndpoint(endpoint string, timeout time.Duration) TestEndpointResult {
r := TestEndpointResult{Address: endpoint} r := TestEndpointResult{Address: endpoint}
start := time.Now() start := time.Now()
@ -242,69 +230,36 @@ func probeTCPEndpoint(endpoint string, timeout time.Duration) TestEndpointResult
return r return r
} }
// probeUDPEndpoint sends a single byte and waits briefly for a reply or // outboundTransportIsUDP reports whether the outbound's proxy speaks UDP
// an ICMP-driven error. WireGuard won't answer an unauthenticated byte, // (wireguard, hysteria, or a kcp/quic/hysteria stream transport). A bare
// so a read timeout is the normal "endpoint reachable" outcome; a // UDP dial can't probe these — they ignore unauthenticated packets, so a
// concrete error (e.g. ECONNREFUSED, "host unreachable") fails the probe. // dial neither proves reachability nor measures latency. Such outbounds
func probeUDPEndpoint(endpoint string, timeout time.Duration) TestEndpointResult { // must go through the real xray handshake probe instead.
r := TestEndpointResult{Address: endpoint} func outboundTransportIsUDP(ob map[string]any) bool {
start := time.Now() if protocol, _ := ob["protocol"].(string); protocol == "hysteria" || protocol == "wireguard" {
conn, err := net.DialTimeout("udp", endpoint, timeout) return true
if err != nil {
r.Delay = time.Since(start).Milliseconds()
r.Error = err.Error()
return r
} }
defer conn.Close() if stream, ok := ob["streamSettings"].(map[string]any); ok {
if n, _ := stream["network"].(string); n == "hysteria" || n == "kcp" || n == "quic" {
if _, werr := conn.Write([]byte{0}); werr != nil { return true
r.Delay = time.Since(start).Milliseconds()
r.Error = werr.Error()
return r
}
_ = conn.SetReadDeadline(time.Now().Add(timeout))
buf := make([]byte, 64)
_, rerr := conn.Read(buf)
r.Delay = time.Since(start).Milliseconds()
if rerr != nil {
if nerr, ok := rerr.(net.Error); ok && nerr.Timeout() {
r.Success = true
return r
} }
r.Error = rerr.Error()
return r
} }
r.Success = true return false
return r
} }
func extractOutboundEndpoints(ob map[string]any) []outboundEndpoint { func extractOutboundEndpoints(ob map[string]any) []string {
protocol, _ := ob["protocol"].(string) protocol, _ := ob["protocol"].(string)
settings, _ := ob["settings"].(map[string]any) settings, _ := ob["settings"].(map[string]any)
if settings == nil { if settings == nil {
return nil return nil
} }
// Hysteria is QUIC/UDP — detect via the outer protocol or via var out []string
// streamSettings.network so a trojan-with-hysteria transport gets
// probed over UDP too. kcp and quic are also UDP-based.
network := "tcp"
if protocol == "hysteria" || protocol == "wireguard" {
network = "udp"
}
if stream, ok := ob["streamSettings"].(map[string]any); ok {
if n, _ := stream["network"].(string); n == "hysteria" || n == "kcp" || n == "quic" {
network = "udp"
}
}
var out []outboundEndpoint
addServer := func(addr any, port any) { addServer := func(addr any, port any) {
host, _ := addr.(string) host, _ := addr.(string)
p := numAsInt(port) p := numAsInt(port)
if host != "" && p > 0 { if host != "" && p > 0 {
out = append(out, outboundEndpoint{Address: fmt.Sprintf("%s:%d", host, p), Network: network}) out = append(out, fmt.Sprintf("%s:%d", host, p))
} }
} }
switch protocol { switch protocol {
@ -333,7 +288,7 @@ func extractOutboundEndpoints(ob map[string]any) []outboundEndpoint {
for _, p := range peers { for _, p := range peers {
if pm, ok := p.(map[string]any); ok { if pm, ok := p.(map[string]any); ok {
if ep, _ := pm["endpoint"].(string); ep != "" { if ep, _ := pm["endpoint"].(string); ep != "" {
out = append(out, outboundEndpoint{Address: ep, Network: network}) out = append(out, ep)
} }
} }
} }