Skip to content

Commit ea24ef0

Browse files
authored
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.
1 parent 2c28fa5 commit ea24ef0

17 files changed

Lines changed: 138 additions & 15 deletions

File tree

frontend/src/pages/xray/basics/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,5 @@ export const SERVICES_OPTIONS = [
6060
];
6161

6262
export const directSettings = { tag: 'direct', protocol: 'freedom' };
63+
export const blockedSettings = { tag: 'blocked', protocol: 'blackhole', settings: {} };
6364
export const ipv4Settings = { tag: 'IPv4', protocol: 'freedom', settings: { domainStrategy: 'UseIPv4' } };

frontend/src/pages/xray/basics/helpers.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { XraySettingsValue } from '@/hooks/useXraySetting';
2+
import { blockedSettings, directSettings } from './constants';
23

34
export function ruleGetter(t: XraySettingsValue | null, outboundTag: string, property: string): string[] {
45
if (!t?.routing?.rules) return [];
@@ -55,6 +56,28 @@ export function syncOutbound(t: XraySettingsValue, tag: string, settings: Record
5556
if (haveRules && idx < 0) t.outbounds.push(settings as never);
5657
}
5758

59+
export function getDefaultOutboundTag(t: XraySettingsValue | null): string {
60+
const tag = t?.outbounds?.[0]?.tag;
61+
return typeof tag === 'string' && tag.length > 0 ? tag : 'direct';
62+
}
63+
64+
export function setDefaultOutboundTag(t: XraySettingsValue, tag: string): void {
65+
if (!tag) return;
66+
if (!Array.isArray(t.outbounds)) t.outbounds = [];
67+
const idx = t.outbounds.findIndex((o) => o?.tag === tag);
68+
if (idx < 0) {
69+
if (tag === 'direct') t.outbounds.push(directSettings as never);
70+
else if (tag === 'blocked') t.outbounds.push(blockedSettings as never);
71+
else return;
72+
const newIdx = t.outbounds.length - 1;
73+
const [moved] = t.outbounds.splice(newIdx, 1);
74+
t.outbounds.unshift(moved);
75+
} else if (idx > 0) {
76+
const [moved] = t.outbounds.splice(idx, 1);
77+
t.outbounds.unshift(moved);
78+
}
79+
}
80+
5881
export function propagateOutboundTagRename(
5982
t: XraySettingsValue,
6083
oldTag: string,

frontend/src/pages/xray/routing/RoutingBasic.tsx

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback } from 'react';
1+
import { useCallback, useMemo } from 'react';
22
import { useTranslation } from 'react-i18next';
33
import { Alert, Select, Switch } from 'antd';
44

@@ -13,7 +13,7 @@ import {
1313
directSettings,
1414
ipv4Settings,
1515
} from '../basics/constants';
16-
import { ruleGetter, ruleSetter, syncOutbound } from '../basics/helpers';
16+
import { getDefaultOutboundTag, ruleGetter, ruleSetter, setDefaultOutboundTag, syncOutbound } from '../basics/helpers';
1717

1818
interface RoutingBasicProps {
1919
templateSettings: XraySettingsValue | null;
@@ -43,6 +43,14 @@ export default function RoutingBasic({ templateSettings, setTemplateSettings }:
4343
const ipv4Domains = ruleGetter(templateSettings, 'IPv4', 'domain');
4444

4545
const torrentActive = BITTORRENT_PROTOCOLS.every((p) => blockedProtocols.includes(p));
46+
const defaultOutboundTag = getDefaultOutboundTag(templateSettings);
47+
const defaultOutboundOptions = useMemo(() => {
48+
const tags = new Set<string>(['direct', 'blocked']);
49+
for (const o of templateSettings?.outbounds ?? []) {
50+
if (o?.tag) tags.add(o.tag);
51+
}
52+
return [...tags].map((value) => ({ label: value, value }));
53+
}, [templateSettings?.outbounds]);
4654

4755
return (
4856
<>
@@ -53,6 +61,20 @@ export default function RoutingBasic({ templateSettings, setTemplateSettings }:
5361
title={t('pages.xray.blockConnectionsConfigsDesc')}
5462
/>
5563

64+
<SettingListItem
65+
title={t('pages.xray.defaultOutbound')}
66+
description={t('pages.xray.defaultOutboundDesc')}
67+
paddings="small"
68+
control={
69+
<Select
70+
value={defaultOutboundTag}
71+
style={{ width: '100%' }}
72+
options={defaultOutboundOptions}
73+
onChange={(tag) => mutate((tt) => setDefaultOutboundTag(tt, tag))}
74+
/>
75+
}
76+
/>
77+
5678
<SettingListItem
5779
title={t('pages.xray.Torrent')}
5880
paddings="small"
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import type { XraySettingsValue } from '@/hooks/useXraySetting';
4+
import { getDefaultOutboundTag, setDefaultOutboundTag } from '@/pages/xray/basics/helpers';
5+
6+
function tpl(
7+
outbounds: Array<{ tag?: string; protocol?: string; settings?: unknown }>,
8+
rules: Array<{ type: string; outboundTag?: string; ip?: string[]; protocol?: string[] }> = [],
9+
): XraySettingsValue {
10+
return { outbounds, routing: { rules } } as XraySettingsValue;
11+
}
12+
13+
describe('routing default outbound', () => {
14+
it('reads first outbound tag', () => {
15+
expect(getDefaultOutboundTag(tpl([{ tag: 'warp', protocol: 'socks' }, { tag: 'direct', protocol: 'freedom' }]))).toBe('warp');
16+
expect(getDefaultOutboundTag(tpl([]))).toBe('direct');
17+
});
18+
19+
it('moves existing outbound to first position', () => {
20+
const tt = tpl([
21+
{ tag: 'direct', protocol: 'freedom' },
22+
{ tag: 'warp', protocol: 'socks' },
23+
{ tag: 'blocked', protocol: 'blackhole' },
24+
]);
25+
setDefaultOutboundTag(tt, 'warp');
26+
expect(tt.outbounds!.map((o) => o?.tag)).toEqual(['warp', 'direct', 'blocked']);
27+
});
28+
29+
it('creates blocked outbound when missing', () => {
30+
const tt = tpl([{ tag: 'direct', protocol: 'freedom' }]);
31+
setDefaultOutboundTag(tt, 'blocked');
32+
expect(tt.outbounds![0]?.tag).toBe('blocked');
33+
expect(tt.outbounds![0]?.protocol).toBe('blackhole');
34+
});
35+
36+
it('does not prune direct when only blocked rules reference an outbound', () => {
37+
const tt = tpl(
38+
[
39+
{ tag: 'direct', protocol: 'freedom', settings: { domainStrategy: 'AsIs' } },
40+
{ tag: 'blocked', protocol: 'blackhole' },
41+
{ tag: 'warp', protocol: 'socks' },
42+
],
43+
[
44+
{ type: 'field', ip: ['geoip:private'], outboundTag: 'blocked' },
45+
{ type: 'field', protocol: ['bittorrent'], outboundTag: 'blocked' },
46+
],
47+
);
48+
setDefaultOutboundTag(tt, 'warp');
49+
expect(tt.outbounds!.map((o) => o?.tag)).toEqual(['warp', 'direct', 'blocked']);
50+
});
51+
});

internal/web/translation/ar-EG.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1866,7 +1866,9 @@
18661866
"edit": "عدل Fake DNS",
18671867
"ipPool": "نطاق IP Pool",
18681868
"poolSize": "حجم المجموعة"
1869-
}
1869+
},
1870+
"defaultOutbound": "الصادر الافتراضي",
1871+
"defaultOutboundDesc": "الحركة التي لا تطابق أي قاعدة توجيه تستخدم هذا الصادر (الأول في القائمة)."
18701872
},
18711873
"hosts": {
18721874
"addHost": "إضافة مضيف",

internal/web/translation/en-US.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1983,7 +1983,9 @@
19831983
"edit": "Edit Fake DNS",
19841984
"ipPool": "IP Pool Subnet",
19851985
"poolSize": "Pool Size"
1986-
}
1986+
},
1987+
"defaultOutbound": "Default Outbound",
1988+
"defaultOutboundDesc": "Traffic that does not match any routing rule uses this outbound (Xray uses the first outbound in the list)."
19871989
}
19881990
},
19891991
"tgbot": {

internal/web/translation/es-ES.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1866,7 +1866,9 @@
18661866
"edit": "Editar DNS Falso",
18671867
"ipPool": "Subred del grupo de IP",
18681868
"poolSize": "Tamaño del grupo"
1869-
}
1869+
},
1870+
"defaultOutbound": "Salida predeterminada",
1871+
"defaultOutboundDesc": "El tráfico que no coincide con ninguna regla de enrutamiento usa esta salida (la primera de la lista)."
18701872
},
18711873
"hosts": {
18721874
"addHost": "Agregar host",

internal/web/translation/fa-IR.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1866,7 +1866,9 @@
18661866
"edit": "ویرایش دی‌ان‌اس جعلی",
18671867
"ipPool": "زیرشبکه استخر آی‌پی",
18681868
"poolSize": "اندازه استخر"
1869-
}
1869+
},
1870+
"defaultOutbound": "خروجی پیش‌فرض",
1871+
"defaultOutboundDesc": "ترافیکی که با هیچ قانون مسیریابی جور نشود از این خروجی استفاده می‌کند (اولین خروجی در فهرست)."
18701872
},
18711873
"hosts": {
18721874
"addHost": "افزودن میزبان",

internal/web/translation/id-ID.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1866,7 +1866,9 @@
18661866
"edit": "Edit DNS Palsu",
18671867
"ipPool": "Subnet Kumpulan IP",
18681868
"poolSize": "Ukuran Kolam"
1869-
}
1869+
},
1870+
"defaultOutbound": "Outbound default",
1871+
"defaultOutboundDesc": "Lalu lintas tanpa aturan routing memakai outbound ini (yang pertama dalam daftar)."
18701872
},
18711873
"hosts": {
18721874
"addHost": "Tambah Host",

internal/web/translation/ja-JP.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1866,7 +1866,9 @@
18661866
"edit": "フェイクDNS編集",
18671867
"ipPool": "IPプールサブネット",
18681868
"poolSize": "プールサイズ"
1869-
}
1869+
},
1870+
"defaultOutbound": "デフォルトアウトバウンド",
1871+
"defaultOutboundDesc": "ルーティング規則に一致しないトラフィックはこのアウトバウンドを使います(一覧の先頭)。"
18701872
},
18711873
"hosts": {
18721874
"addHost": "ホストを追加",

0 commit comments

Comments
 (0)