Browse Source

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.
Rouzbeh† 14 hours ago
parent
commit
ea24ef0a69

+ 1 - 0
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' } };

+ 23 - 0
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,

+ 24 - 2
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<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"

+ 51 - 0
frontend/src/test/routing-default-outbound.test.ts

@@ -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']);
+  });
+});

+ 3 - 1
internal/web/translation/ar-EG.json

@@ -1866,7 +1866,9 @@
         "edit": "عدل Fake DNS",
         "ipPool": "نطاق IP Pool",
         "poolSize": "حجم المجموعة"
-      }
+      },
+      "defaultOutbound": "الصادر الافتراضي",
+      "defaultOutboundDesc": "الحركة التي لا تطابق أي قاعدة توجيه تستخدم هذا الصادر (الأول في القائمة)."
     },
     "hosts": {
       "addHost": "إضافة مضيف",

+ 3 - 1
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": {

+ 3 - 1
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",

+ 3 - 1
internal/web/translation/fa-IR.json

@@ -1866,7 +1866,9 @@
         "edit": "ویرایش دی‌ان‌اس جعلی",
         "ipPool": "زیرشبکه استخر آی‌پی",
         "poolSize": "اندازه استخر"
-      }
+      },
+      "defaultOutbound": "خروجی پیش‌فرض",
+      "defaultOutboundDesc": "ترافیکی که با هیچ قانون مسیریابی جور نشود از این خروجی استفاده می‌کند (اولین خروجی در فهرست)."
     },
     "hosts": {
       "addHost": "افزودن میزبان",

+ 3 - 1
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",

+ 3 - 1
internal/web/translation/ja-JP.json

@@ -1866,7 +1866,9 @@
         "edit": "フェイクDNS編集",
         "ipPool": "IPプールサブネット",
         "poolSize": "プールサイズ"
-      }
+      },
+      "defaultOutbound": "デフォルトアウトバウンド",
+      "defaultOutboundDesc": "ルーティング規則に一致しないトラフィックはこのアウトバウンドを使います(一覧の先頭)。"
     },
     "hosts": {
       "addHost": "ホストを追加",

+ 3 - 1
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",

+ 3 - 1
internal/web/translation/ru-RU.json

@@ -1866,7 +1866,9 @@
         "edit": "Редактировать Fake DNS",
         "ipPool": "Подсеть пула IP",
         "poolSize": "Размер пула"
-      }
+      },
+      "defaultOutbound": "Исходящий по умолчанию",
+      "defaultOutboundDesc": "Трафик без совпадения с правилами маршрутизации идёт через этот исходящий (первый в списке)."
     },
     "hosts": {
       "addHost": "Добавить хост",

+ 3 - 1
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",

+ 3 - 1
internal/web/translation/uk-UA.json

@@ -1866,7 +1866,9 @@
         "edit": "Редагувати підроблений DNS",
         "ipPool": "Підмережа IP-пулу",
         "poolSize": "Розмір пулу"
-      }
+      },
+      "defaultOutbound": "Вихідний за замовчуванням",
+      "defaultOutboundDesc": "Трафік без збігу з правилами маршрутизації йде через цей вихідний (перший у списку)."
     },
     "hosts": {
       "addHost": "Додати хост",

+ 3 - 1
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",

+ 3 - 1
internal/web/translation/zh-CN.json

@@ -1866,7 +1866,9 @@
         "edit": "编辑假 DNS",
         "ipPool": "IP 池子网",
         "poolSize": "池大小"
-      }
+      },
+      "defaultOutbound": "默认出站",
+      "defaultOutboundDesc": "未匹配任何路由规则的流量走此出站(列表中的第一个出站)。"
     },
     "hosts": {
       "addHost": "添加主机",

+ 3 - 1
internal/web/translation/zh-TW.json

@@ -1866,7 +1866,9 @@
         "edit": "編輯假 DNS",
         "ipPool": "IP 池子網",
         "poolSize": "池大小"
-      }
+      },
+      "defaultOutbound": "預設出站",
+      "defaultOutboundDesc": "未符合任何路由規則的流量走此出站(清單中的第一個出站)。"
     },
     "hosts": {
       "addHost": "新增 Host",