diff --git a/frontend/src/pages/xray/basics/constants.ts b/frontend/src/pages/xray/basics/constants.ts index 59031fe40..160d797a2 100644 --- a/frontend/src/pages/xray/basics/constants.ts +++ b/frontend/src/pages/xray/basics/constants.ts @@ -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' } }; diff --git a/frontend/src/pages/xray/basics/helpers.ts b/frontend/src/pages/xray/basics/helpers.ts index 48df3e0c9..750644aec 100644 --- a/frontend/src/pages/xray/basics/helpers.ts +++ b/frontend/src/pages/xray/basics/helpers.ts @@ -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, diff --git a/frontend/src/pages/xray/routing/RoutingBasic.tsx b/frontend/src/pages/xray/routing/RoutingBasic.tsx index a6e601795..bd0d44e3d 100644 --- a/frontend/src/pages/xray/routing/RoutingBasic.tsx +++ b/frontend/src/pages/xray/routing/RoutingBasic.tsx @@ -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(['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')} /> + mutate((tt) => setDefaultOutboundTag(tt, tag))} + /> + } + /> + , + 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']); + }); +}); diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index 34d65e138..391ebe8d2 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -1866,7 +1866,9 @@ "edit": "عدل Fake DNS", "ipPool": "نطاق IP Pool", "poolSize": "حجم المجموعة" - } + }, + "defaultOutbound": "الصادر الافتراضي", + "defaultOutboundDesc": "الحركة التي لا تطابق أي قاعدة توجيه تستخدم هذا الصادر (الأول في القائمة)." }, "hosts": { "addHost": "إضافة مضيف", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index 6305e6c1d..c456da2c8 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -1983,7 +1983,9 @@ "edit": "Edit Fake DNS", "ipPool": "IP Pool Subnet", "poolSize": "Pool Size" - } + }, + "defaultOutbound": "Default Outbound", + "defaultOutboundDesc": "Traffic that does not match any routing rule uses this outbound (Xray uses the first outbound in the list)." } }, "tgbot": { diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index 076e792f6..4437e1111 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -1866,7 +1866,9 @@ "edit": "Editar DNS Falso", "ipPool": "Subred del grupo de IP", "poolSize": "Tamaño del grupo" - } + }, + "defaultOutbound": "Salida predeterminada", + "defaultOutboundDesc": "El tráfico que no coincide con ninguna regla de enrutamiento usa esta salida (la primera de la lista)." }, "hosts": { "addHost": "Agregar host", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index 5f73a02df..91f8294c7 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -1866,7 +1866,9 @@ "edit": "ویرایش دی‌ان‌اس جعلی", "ipPool": "زیرشبکه استخر آی‌پی", "poolSize": "اندازه استخر" - } + }, + "defaultOutbound": "خروجی پیش‌فرض", + "defaultOutboundDesc": "ترافیکی که با هیچ قانون مسیریابی جور نشود از این خروجی استفاده می‌کند (اولین خروجی در فهرست)." }, "hosts": { "addHost": "افزودن میزبان", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index 6b678f2ca..9e0425510 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -1866,7 +1866,9 @@ "edit": "Edit DNS Palsu", "ipPool": "Subnet Kumpulan IP", "poolSize": "Ukuran Kolam" - } + }, + "defaultOutbound": "Outbound default", + "defaultOutboundDesc": "Lalu lintas tanpa aturan routing memakai outbound ini (yang pertama dalam daftar)." }, "hosts": { "addHost": "Tambah Host", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index f6ef869f0..4b5688e57 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -1866,7 +1866,9 @@ "edit": "フェイクDNS編集", "ipPool": "IPプールサブネット", "poolSize": "プールサイズ" - } + }, + "defaultOutbound": "デフォルトアウトバウンド", + "defaultOutboundDesc": "ルーティング規則に一致しないトラフィックはこのアウトバウンドを使います(一覧の先頭)。" }, "hosts": { "addHost": "ホストを追加", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index baac13d61..d3053a279 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -1866,7 +1866,9 @@ "edit": "Editar Fake DNS", "ipPool": "Sub-rede do Pool de IP", "poolSize": "Tamanho do Pool" - } + }, + "defaultOutbound": "Saída padrão", + "defaultOutboundDesc": "Tráfego sem regra de roteamento usa esta saída (a primeira da lista)." }, "hosts": { "addHost": "Adicionar Host", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index 460d44956..e603d8b11 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -1866,7 +1866,9 @@ "edit": "Редактировать Fake DNS", "ipPool": "Подсеть пула IP", "poolSize": "Размер пула" - } + }, + "defaultOutbound": "Исходящий по умолчанию", + "defaultOutboundDesc": "Трафик без совпадения с правилами маршрутизации идёт через этот исходящий (первый в списке)." }, "hosts": { "addHost": "Добавить хост", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 99e2e1366..7bf9c3a4b 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -1866,7 +1866,9 @@ "edit": "Sahte DNS'i Düzenle", "ipPool": "IP Havuzu Alt Ağı", "poolSize": "Havuz Boyutu" - } + }, + "defaultOutbound": "Varsayılan giden", + "defaultOutboundDesc": "Yönlendirme kuralıyla eşleşmeyen trafik bu gideni kullanır (listedeki ilk giden)." }, "hosts": { "addHost": "Host Ekle", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index a5488e237..c0d127c8f 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -1866,7 +1866,9 @@ "edit": "Редагувати підроблений DNS", "ipPool": "Підмережа IP-пулу", "poolSize": "Розмір пулу" - } + }, + "defaultOutbound": "Вихідний за замовчуванням", + "defaultOutboundDesc": "Трафік без збігу з правилами маршрутизації йде через цей вихідний (перший у списку)." }, "hosts": { "addHost": "Додати хост", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 295383f71..2d70a1657 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -1866,7 +1866,9 @@ "edit": "Chỉnh sửa DNS giả", "ipPool": "Mạng con nhóm IP", "poolSize": "Kích thước bể bơi" - } + }, + "defaultOutbound": "Outbound mặc định", + "defaultOutboundDesc": "Lưu lượng không khớp quy tắc định tuyến dùng outbound này (mục đầu danh sách)." }, "hosts": { "addHost": "Thêm Host", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index f2b0c6106..6e0613d61 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -1866,7 +1866,9 @@ "edit": "编辑假 DNS", "ipPool": "IP 池子网", "poolSize": "池大小" - } + }, + "defaultOutbound": "默认出站", + "defaultOutboundDesc": "未匹配任何路由规则的流量走此出站(列表中的第一个出站)。" }, "hosts": { "addHost": "添加主机", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 5f7cbbbf0..ec8a02482 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -1866,7 +1866,9 @@ "edit": "編輯假 DNS", "ipPool": "IP 池子網", "poolSize": "池大小" - } + }, + "defaultOutbound": "預設出站", + "defaultOutboundDesc": "未符合任何路由規則的流量走此出站(清單中的第一個出站)。" }, "hosts": { "addHost": "新增 Host",