feat(xray): default outbound in basic routing (#5815)

* feat(xray): default outbound picker in basic routing

Let panel users choose which outbound handles unmatched traffic by
moving it to the first position in the template outbounds list.

* fix(xray): keep direct/blocked outbounds when changing default

* style(routing): revert incidental whitespace churn

Drop double blank lines and the reformatted function signature so the default-outbound diff stays focused on behavior.
This commit is contained in:
Rouzbeh†
2026-07-09 03:25:47 +03:30
committed by GitHub
parent 2c28fa5f48
commit ea24ef0a69
17 changed files with 138 additions and 15 deletions
@@ -60,4 +60,5 @@ export const SERVICES_OPTIONS = [
];
export const directSettings = { tag: 'direct', protocol: 'freedom' };
export const blockedSettings = { tag: 'blocked', protocol: 'blackhole', settings: {} };
export const ipv4Settings = { tag: 'IPv4', protocol: 'freedom', settings: { domainStrategy: 'UseIPv4' } };
+23
View File
@@ -1,4 +1,5 @@
import type { XraySettingsValue } from '@/hooks/useXraySetting';
import { blockedSettings, directSettings } from './constants';
export function ruleGetter(t: XraySettingsValue | null, outboundTag: string, property: string): string[] {
if (!t?.routing?.rules) return [];
@@ -55,6 +56,28 @@ export function syncOutbound(t: XraySettingsValue, tag: string, settings: Record
if (haveRules && idx < 0) t.outbounds.push(settings as never);
}
export function getDefaultOutboundTag(t: XraySettingsValue | null): string {
const tag = t?.outbounds?.[0]?.tag;
return typeof tag === 'string' && tag.length > 0 ? tag : 'direct';
}
export function setDefaultOutboundTag(t: XraySettingsValue, tag: string): void {
if (!tag) return;
if (!Array.isArray(t.outbounds)) t.outbounds = [];
const idx = t.outbounds.findIndex((o) => o?.tag === tag);
if (idx < 0) {
if (tag === 'direct') t.outbounds.push(directSettings as never);
else if (tag === 'blocked') t.outbounds.push(blockedSettings as never);
else return;
const newIdx = t.outbounds.length - 1;
const [moved] = t.outbounds.splice(newIdx, 1);
t.outbounds.unshift(moved);
} else if (idx > 0) {
const [moved] = t.outbounds.splice(idx, 1);
t.outbounds.unshift(moved);
}
}
export function propagateOutboundTagRename(
t: XraySettingsValue,
oldTag: string,
@@ -1,4 +1,4 @@
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Select, Switch } from 'antd';
@@ -13,7 +13,7 @@ import {
directSettings,
ipv4Settings,
} from '../basics/constants';
import { ruleGetter, ruleSetter, syncOutbound } from '../basics/helpers';
import { getDefaultOutboundTag, ruleGetter, ruleSetter, setDefaultOutboundTag, syncOutbound } from '../basics/helpers';
interface RoutingBasicProps {
templateSettings: XraySettingsValue | null;
@@ -43,6 +43,14 @@ export default function RoutingBasic({ templateSettings, setTemplateSettings }:
const ipv4Domains = ruleGetter(templateSettings, 'IPv4', 'domain');
const torrentActive = BITTORRENT_PROTOCOLS.every((p) => blockedProtocols.includes(p));
const defaultOutboundTag = getDefaultOutboundTag(templateSettings);
const defaultOutboundOptions = useMemo(() => {
const tags = new Set<string>(['direct', 'blocked']);
for (const o of templateSettings?.outbounds ?? []) {
if (o?.tag) tags.add(o.tag);
}
return [...tags].map((value) => ({ label: value, value }));
}, [templateSettings?.outbounds]);
return (
<>
@@ -53,6 +61,20 @@ export default function RoutingBasic({ templateSettings, setTemplateSettings }:
title={t('pages.xray.blockConnectionsConfigsDesc')}
/>
<SettingListItem
title={t('pages.xray.defaultOutbound')}
description={t('pages.xray.defaultOutboundDesc')}
paddings="small"
control={
<Select
value={defaultOutboundTag}
style={{ width: '100%' }}
options={defaultOutboundOptions}
onChange={(tag) => mutate((tt) => setDefaultOutboundTag(tt, tag))}
/>
}
/>
<SettingListItem
title={t('pages.xray.Torrent')}
paddings="small"
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import type { XraySettingsValue } from '@/hooks/useXraySetting';
import { getDefaultOutboundTag, setDefaultOutboundTag } from '@/pages/xray/basics/helpers';
function tpl(
outbounds: Array<{ tag?: string; protocol?: string; settings?: unknown }>,
rules: Array<{ type: string; outboundTag?: string; ip?: string[]; protocol?: string[] }> = [],
): XraySettingsValue {
return { outbounds, routing: { rules } } as XraySettingsValue;
}
describe('routing default outbound', () => {
it('reads first outbound tag', () => {
expect(getDefaultOutboundTag(tpl([{ tag: 'warp', protocol: 'socks' }, { tag: 'direct', protocol: 'freedom' }]))).toBe('warp');
expect(getDefaultOutboundTag(tpl([]))).toBe('direct');
});
it('moves existing outbound to first position', () => {
const tt = tpl([
{ tag: 'direct', protocol: 'freedom' },
{ tag: 'warp', protocol: 'socks' },
{ tag: 'blocked', protocol: 'blackhole' },
]);
setDefaultOutboundTag(tt, 'warp');
expect(tt.outbounds!.map((o) => o?.tag)).toEqual(['warp', 'direct', 'blocked']);
});
it('creates blocked outbound when missing', () => {
const tt = tpl([{ tag: 'direct', protocol: 'freedom' }]);
setDefaultOutboundTag(tt, 'blocked');
expect(tt.outbounds![0]?.tag).toBe('blocked');
expect(tt.outbounds![0]?.protocol).toBe('blackhole');
});
it('does not prune direct when only blocked rules reference an outbound', () => {
const tt = tpl(
[
{ tag: 'direct', protocol: 'freedom', settings: { domainStrategy: 'AsIs' } },
{ tag: 'blocked', protocol: 'blackhole' },
{ tag: 'warp', protocol: 'socks' },
],
[
{ type: 'field', ip: ['geoip:private'], outboundTag: 'blocked' },
{ type: 'field', protocol: ['bittorrent'], outboundTag: 'blocked' },
],
);
setDefaultOutboundTag(tt, 'warp');
expect(tt.outbounds!.map((o) => o?.tag)).toEqual(['warp', 'direct', 'blocked']);
});
});