Ver Fonte

feat(frontend): add targetStrategy field to the outbound editor

Xray-core added a top-level targetStrategy to OutboundObject that
controls how the destination domain is resolved before dialing
(AsIs/UseIP*/ForceIP*, any protocol). The panel neither offered a
control for it nor preserved the key across the modal's JSON round
trip, so hand-written values were silently dropped on save.

The form now carries targetStrategy next to sendThrough as a select
of the 11 canonical values; the adapter normalizes wire values to
canonical case (the core matches case-insensitively) and omits the
key when unset. Freedom settings additionally read the new
settings-level targetStrategy with domainStrategy as fallback,
mirroring the core, while still emitting the legacy domainStrategy
key so configs keep working on older cores.
MHSanaei há 16 horas atrás
pai
commit
258d8b7344

+ 21 - 8
frontend/src/lib/xray/outbound-form-adapter.ts

@@ -1,7 +1,9 @@
 import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
+import { OutboundDomainStrategySchema } from '@/schemas/protocols/outbound';
 import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
 import { Wireguard } from '@/utils';
 import type { Sniffing, SniffingDest } from '@/schemas/primitives';
+import type { OutboundDomainStrategy } from '@/schemas/protocols/outbound';
 
 import type {
   DnsOutboundFormSettings,
@@ -56,6 +58,16 @@ function asPort(value: unknown, fallback: number): number {
   return n;
 }
 
+// xray-core matches targetStrategy/domainStrategy case-insensitively;
+// normalize the wire value to the canonical spelling or '' (= AsIs).
+function targetStrategyFromWire(value: unknown): OutboundDomainStrategy | '' {
+  const s = asString(value);
+  if (!s) return '';
+  return OutboundDomainStrategySchema.options.find(
+    (v) => v.toLowerCase() === s.toLowerCase(),
+  ) ?? '';
+}
+
 const SNIFFING_DEST_VALUES: readonly SniffingDest[] = ['http', 'tls', 'quic', 'fakedns'];
 
 const SNIFFING_DEFAULT: Sniffing = {
@@ -285,14 +297,9 @@ function freedomFromWire(raw: Raw): FreedomOutboundFormSettings {
     && typeof raw.fragment === 'object'
     && Object.keys(fragment).length > 0;
   return {
-    domainStrategy: ((): FreedomOutboundFormSettings['domainStrategy'] => {
-      const allowed = [
-        'AsIs', 'UseIP', 'UseIPv4', 'UseIPv6', 'UseIPv6v4', 'UseIPv4v6',
-        'ForceIP', 'ForceIPv6v4', 'ForceIPv6', 'ForceIPv4v6', 'ForceIPv4',
-      ];
-      const s = asString(raw.domainStrategy);
-      return (allowed.includes(s) ? s : '') as FreedomOutboundFormSettings['domainStrategy'];
-    })(),
+    domainStrategy: targetStrategyFromWire(
+      asString(raw.targetStrategy) || asString(raw.domainStrategy),
+    ),
     redirect: asString(raw.redirect),
     userLevel: asNumber(raw.userLevel, 0),
     proxyProtocol: ((): FreedomOutboundFormSettings['proxyProtocol'] => {
@@ -374,6 +381,7 @@ export interface RawOutboundRow {
   tag?: string;
   protocol?: string;
   sendThrough?: string;
+  targetStrategy?: string;
   settings?: unknown;
   streamSettings?: unknown;
   mux?: unknown;
@@ -401,6 +409,7 @@ export function rawOutboundToFormValues(raw: RawOutboundRow): OutboundFormValues
   const settings = asObject(raw.settings);
   const tag = asString(raw.tag);
   const sendThrough = asString(raw.sendThrough);
+  const targetStrategy = targetStrategyFromWire(raw.targetStrategy);
   const mux = muxFromWire(raw.mux);
   const hasStream = raw.streamSettings
     && typeof raw.streamSettings === 'object'
@@ -430,6 +439,7 @@ export function rawOutboundToFormValues(raw: RawOutboundRow): OutboundFormValues
     ...typed,
     tag,
     sendThrough,
+    targetStrategy,
     mux,
     streamSettings,
   };
@@ -543,6 +553,8 @@ function hysteriaToWire(s: HysteriaOutboundFormSettings) {
 }
 
 function freedomToWire(s: FreedomOutboundFormSettings) {
+  // The strategy is emitted under the legacy domainStrategy key: new cores
+  // fall back to it when targetStrategy is absent, old cores only know it.
   // Legacy semantics: emit fragment only when the user actually populated
   // at least one of the four sub-fields. Defaults like packets='1-3' alone
   // are not enough — the modal's Fragment Switch sets all four together.
@@ -672,6 +684,7 @@ export function formValuesToWirePayload(values: OutboundFormValues): WireOutboun
     settings,
   };
   if (values.tag) result.tag = values.tag;
+  if (values.targetStrategy) result.targetStrategy = values.targetStrategy;
 
   // streamSettings emission gates on canEnableStream — non-stream protocols
   // still emit just `sockopt` if that key is present (legacy behavior).

+ 9 - 0
frontend/src/pages/xray/outbounds/OutboundFormModal.tsx

@@ -39,6 +39,7 @@ import {
   NETWORK_OPTIONS,
   PROTOCOL_OPTIONS,
   SERVER_PROTOCOLS,
+  TARGET_STRATEGY_OPTIONS,
 } from './outbound-form-constants';
 import {
   applyNetworkChange,
@@ -394,6 +395,14 @@ export default function OutboundFormModal({
                       <Input placeholder={t('pages.xray.outboundForm.localIpPlaceholder')} />
                     </Form.Item>
 
+                    <Form.Item
+                      label={t('pages.xray.outbound.targetStrategy')}
+                      name="targetStrategy"
+                      tooltip={t('pages.xray.outboundForm.targetStrategyHint')}
+                    >
+                      <Select allowClear placeholder="AsIs" options={TARGET_STRATEGY_OPTIONS} />
+                    </Form.Item>
+
                     {SERVER_PROTOCOLS.has(protocol) && <ServerTarget />}
                     {protocol === 'vmess' && <VmessFields />}
                     {protocol === 'vless' && <VlessFields />}

+ 5 - 0
frontend/src/pages/xray/outbounds/outbound-form-constants.ts

@@ -7,6 +7,7 @@ import {
   USERS_SECURITY,
   UTLS_FINGERPRINT,
 } from '@/schemas/primitives';
+import { OutboundDomainStrategySchema } from '@/schemas/protocols/outbound';
 import { SSMethodSchema } from '@/schemas/protocols/shared/shadowsocks';
 
 export const PROTOCOL_OPTIONS = Object.values(Protocols).map((p) => ({ value: p, label: p }));
@@ -20,6 +21,10 @@ export const ADDRESS_PORT_STRATEGY_OPTIONS = Object.values(Address_Port_Strategy
   value: v,
   label: v,
 }));
+export const TARGET_STRATEGY_OPTIONS = OutboundDomainStrategySchema.options.map((v) => ({
+  value: v,
+  label: v,
+}));
 
 // canEnableMux mirrors the adapter's helper but lives here so the modal
 // can show/hide the Mux section without going through the adapter.

+ 4 - 2
frontend/src/schemas/forms/outbound-form.ts

@@ -219,11 +219,13 @@ export const OutboundStreamFormSchema = NetworkSettingsSchema
   .and(StreamExtrasSchema);
 export type OutboundStreamFormValues = z.infer<typeof OutboundStreamFormSchema>;
 
-// Top-level form base: identity (tag, sendThrough), then the per-protocol
-// settings DU, then the stream sub-form, then mux.
+// Top-level form base: identity (tag, sendThrough, targetStrategy), then
+// the per-protocol settings DU, then the stream sub-form, then mux.
+// targetStrategy '' means AsIs (omitted from wire).
 export const OutboundFormBaseSchema = z.object({
   tag: z.string().default(''),
   sendThrough: z.string().default(''),
+  targetStrategy: z.union([OutboundDomainStrategySchema, z.literal('')]).default(''),
   streamSettings: OutboundStreamFormSchema.optional(),
   mux: MuxFormSchema.default({
     enabled: false,

+ 48 - 0
frontend/src/test/outbound-form-adapter.test.ts

@@ -420,6 +420,54 @@ describe('outbound-form-adapter: round-trip', () => {
   });
 });
 
+describe('outbound-form-adapter: targetStrategy', () => {
+  it('round-trips a top-level targetStrategy', () => {
+    const back = formValuesToWirePayload(rawOutboundToFormValues({
+      protocol: 'vless',
+      settings: { address: 's', port: 443, id: '11111111-2222-4333-8444-555555555555', flow: '', encryption: 'none' },
+      targetStrategy: 'ForceIPv6v4',
+    }));
+    expect(back.targetStrategy).toBe('ForceIPv6v4');
+  });
+
+  it('normalizes wire case to the canonical spelling (core matches case-insensitively)', () => {
+    const form = rawOutboundToFormValues({
+      protocol: 'freedom',
+      settings: {},
+      targetStrategy: 'useipv4v6',
+    });
+    expect(form.targetStrategy).toBe('UseIPv4v6');
+  });
+
+  it('omits targetStrategy when unset and drops unknown values', () => {
+    const unset = formValuesToWirePayload(rawOutboundToFormValues({
+      protocol: 'freedom',
+      settings: {},
+    }));
+    expect(unset).not.toHaveProperty('targetStrategy');
+
+    const invalid = formValuesToWirePayload(rawOutboundToFormValues({
+      protocol: 'freedom',
+      settings: {},
+      targetStrategy: 'UseIPv5',
+    }));
+    expect(invalid).not.toHaveProperty('targetStrategy');
+  });
+
+  it('freedom prefers settings.targetStrategy over domainStrategy and emits the legacy key', () => {
+    const form = rawOutboundToFormValues({
+      protocol: 'freedom',
+      settings: { targetStrategy: 'UseIPv6', domainStrategy: 'UseIPv4' },
+    });
+    if (form.protocol === 'freedom') {
+      expect(form.settings.domainStrategy).toBe('UseIPv6');
+    }
+    const back = formValuesToWirePayload(form);
+    expect(back.settings).toMatchObject({ domainStrategy: 'UseIPv6' });
+    expect(back.settings).not.toHaveProperty('targetStrategy');
+  });
+});
+
 describe('outbound-form-adapter: xhttp xmux toggle', () => {
   const xmuxWire = {
     protocol: 'vless',

+ 2 - 0
internal/web/translation/ar-EG.json

@@ -1549,6 +1549,7 @@
         "localIpPlaceholder": "IP محلي",
         "dialerProxyPlaceholder": "اختر مخرجًا لتمرير الاتصال عبره",
         "dialerProxyHint": "وجّه هذا المخرج عبر مخرج آخر (حسب الوسم) لبناء سلسلة بروكسي. اتركه فارغًا للاتصال المباشر.",
+        "targetStrategyHint": "كيفية حلّ نطاق الوجهة قبل الاتصال: AsIs (الافتراضي) يرسله كما هو، UseIP… يحلّه مع الرجوع عند الفشل، ForceIP… يشترط نجاح الحلّ.",
         "addressRequired": "العنوان مطلوب",
         "portRequired": "المنفذ مطلوب",
         "optional": "اختياري",
@@ -1618,6 +1619,7 @@
         "accountInfo": "معلومات الحساب",
         "outboundStatus": "حالة المخرج",
         "sendThrough": "أرسل من خلال",
+        "targetStrategy": "استراتيجية الوجهة",
         "test": "اختبار",
         "testResult": "نتيجة الاختبار",
         "testing": "جاري اختبار الاتصال...",

+ 2 - 0
internal/web/translation/en-US.json

@@ -1665,6 +1665,7 @@
         "localIpPlaceholder": "local IP",
         "dialerProxyPlaceholder": "Select an outbound to chain through",
         "dialerProxyHint": "Dial this outbound through another outbound (by tag) to build a proxy chain. Leave empty to connect directly.",
+        "targetStrategyHint": "How the destination domain is resolved before connecting: AsIs (default) sends it unresolved, UseIP… resolves with fallback, ForceIP… requires successful resolution.",
         "addressRequired": "Address is required",
         "portRequired": "Port is required",
         "optional": "optional",
@@ -1734,6 +1735,7 @@
         "accountInfo": "Account Information",
         "outboundStatus": "Outbound Status",
         "sendThrough": "Send Through",
+        "targetStrategy": "Target Strategy",
         "test": "Test",
         "testResult": "Test Result",
         "testing": "Testing connection...",

+ 2 - 0
internal/web/translation/es-ES.json

@@ -1549,6 +1549,7 @@
         "localIpPlaceholder": "IP local",
         "dialerProxyPlaceholder": "Selecciona una salida para encadenar",
         "dialerProxyHint": "Conecta esta salida a través de otra salida (por etiqueta) para crear una cadena de proxy. Déjalo vacío para conectar directamente.",
+        "targetStrategyHint": "Cómo se resuelve el dominio de destino antes de conectar: AsIs (predeterminado) lo envía sin resolver, UseIP… resuelve con respaldo, ForceIP… exige resolución.",
         "addressRequired": "La dirección es obligatoria",
         "portRequired": "El puerto es obligatorio",
         "optional": "opcional",
@@ -1618,6 +1619,7 @@
         "accountInfo": "Información de la Cuenta",
         "outboundStatus": "Estado de Salida",
         "sendThrough": "Enviar a través de",
+        "targetStrategy": "Estrategia de destino",
         "test": "Probar",
         "testResult": "Resultado de la prueba",
         "testing": "Probando conexión...",

+ 2 - 0
internal/web/translation/fa-IR.json

@@ -1549,6 +1549,7 @@
         "localIpPlaceholder": "IP محلی",
         "dialerProxyPlaceholder": "یک خروجی برای زنجیره کردن انتخاب کنید",
         "dialerProxyHint": "این خروجی را از طریق خروجی دیگری (با تگ) برقرار کن تا یک زنجیره پروکسی ساخته شود. برای اتصال مستقیم خالی بگذار.",
+        "targetStrategyHint": "نحوه تبدیل دامنه مقصد پیش از اتصال: AsIs (پیش‌فرض) آن را بدون تغییر می‌فرستد، UseIP… با امکان بازگشت تبدیل می‌کند، ForceIP… تبدیل موفق را الزامی می‌کند.",
         "addressRequired": "آدرس الزامی است",
         "portRequired": "پورت الزامی است",
         "optional": "اختیاری",
@@ -1618,6 +1619,7 @@
         "accountInfo": "اطلاعات حساب",
         "outboundStatus": "وضعیت خروجی",
         "sendThrough": "ارسال با",
+        "targetStrategy": "استراتژی مقصد",
         "test": "تست",
         "testResult": "نتیجه تست",
         "testing": "در حال تست اتصال...",

+ 2 - 0
internal/web/translation/id-ID.json

@@ -1549,6 +1549,7 @@
         "localIpPlaceholder": "IP lokal",
         "dialerProxyPlaceholder": "Pilih outbound untuk dirantai",
         "dialerProxyHint": "Hubungkan outbound ini melalui outbound lain (berdasarkan tag) untuk membuat rantai proxy. Kosongkan untuk terhubung langsung.",
+        "targetStrategyHint": "Cara domain tujuan diresolusi sebelum terhubung: AsIs (default) mengirim apa adanya, UseIP… meresolusi dengan fallback, ForceIP… wajib berhasil diresolusi.",
         "addressRequired": "Alamat wajib diisi",
         "portRequired": "Port wajib diisi",
         "optional": "opsional",
@@ -1618,6 +1619,7 @@
         "accountInfo": "Informasi Akun",
         "outboundStatus": "Status Keluar",
         "sendThrough": "Kirim Melalui",
+        "targetStrategy": "Strategi Target",
         "test": "Tes",
         "testResult": "Hasil Tes",
         "testing": "Menguji koneksi...",

+ 2 - 0
internal/web/translation/ja-JP.json

@@ -1549,6 +1549,7 @@
         "localIpPlaceholder": "ローカル IP",
         "dialerProxyPlaceholder": "経由するアウトバウンドを選択",
         "dialerProxyHint": "このアウトバウンドを別のアウトバウンド(タグ指定)経由で接続し、プロキシチェーンを構成します。直接接続する場合は空のままにします。",
+        "targetStrategyHint": "接続前に宛先ドメインをどう解決するか:AsIs(既定)はそのまま送信、UseIP… は解決を試み失敗時はフォールバック、ForceIP… は解決必須。",
         "addressRequired": "アドレスは必須です",
         "portRequired": "ポートは必須です",
         "optional": "任意",
@@ -1618,6 +1619,7 @@
         "accountInfo": "アカウント情報",
         "outboundStatus": "アウトバウンドステータス",
         "sendThrough": "送信経路",
+        "targetStrategy": "ターゲット解決戦略",
         "test": "テスト",
         "testResult": "テスト結果",
         "testing": "接続をテスト中...",

+ 2 - 0
internal/web/translation/pt-BR.json

@@ -1549,6 +1549,7 @@
         "localIpPlaceholder": "IP local",
         "dialerProxyPlaceholder": "Selecione uma saída para encadear",
         "dialerProxyHint": "Conecte esta saída através de outra saída (por tag) para criar uma cadeia de proxy. Deixe vazio para conectar diretamente.",
+        "targetStrategyHint": "Como o domínio de destino é resolvido antes de conectar: AsIs (padrão) envia sem resolver, UseIP… resolve com fallback, ForceIP… exige resolução.",
         "addressRequired": "Endereço é obrigatório",
         "portRequired": "Porta é obrigatória",
         "optional": "opcional",
@@ -1618,6 +1619,7 @@
         "accountInfo": "Informações da Conta",
         "outboundStatus": "Status de Saída",
         "sendThrough": "Enviar Através de",
+        "targetStrategy": "Estratégia de destino",
         "test": "Testar",
         "testResult": "Resultado do teste",
         "testing": "Testando conexão...",

+ 2 - 0
internal/web/translation/ru-RU.json

@@ -1549,6 +1549,7 @@
         "localIpPlaceholder": "локальный IP",
         "dialerProxyPlaceholder": "Выберите исходящее для цепочки",
         "dialerProxyHint": "Подключайте это исходящее через другое исходящее (по тегу), чтобы построить цепочку прокси. Оставьте пустым для прямого подключения.",
+        "targetStrategyHint": "Как разрешается домен назначения перед подключением: AsIs (по умолчанию) — отправляется как есть, UseIP… — разрешение с откатом, ForceIP… — требуется успешное разрешение.",
         "addressRequired": "Адрес обязателен",
         "portRequired": "Порт обязателен",
         "optional": "опционально",
@@ -1618,6 +1619,7 @@
         "accountInfo": "Информация об учетной записи",
         "outboundStatus": "Статус исходящего подключения",
         "sendThrough": "Отправить через",
+        "targetStrategy": "Стратегия назначения",
         "test": "Тест",
         "testResult": "Результат теста",
         "testing": "Тестирование соединения...",

+ 2 - 0
internal/web/translation/tr-TR.json

@@ -1549,6 +1549,7 @@
         "localIpPlaceholder": "yerel IP",
         "dialerProxyPlaceholder": "Zincirlemek için bir giden bağlantı seçin",
         "dialerProxyHint": "Bir proxy zinciri oluşturmak için bu giden bağlantıyı başka bir giden bağlantı (etikete göre) üzerinden bağlayın. Doğrudan bağlanmak için boş bırakın.",
+        "targetStrategyHint": "Bağlanmadan önce hedef alan adının nasıl çözümleneceği: AsIs (varsayılan) olduğu gibi gönderir, UseIP… çözümler ve başarısızsa geri döner, ForceIP… çözümleme zorunludur.",
         "addressRequired": "Adres zorunludur",
         "portRequired": "Port zorunludur",
         "optional": "opsiyonel",
@@ -1618,6 +1619,7 @@
         "accountInfo": "Hesap Bilgileri",
         "outboundStatus": "Giden Bağlantı Durumu",
         "sendThrough": "Üzerinden Gönder",
+        "targetStrategy": "Hedef Stratejisi",
         "test": "Test",
         "testResult": "Test Sonucu",
         "testing": "Bağlantı test ediliyor...",

+ 2 - 0
internal/web/translation/uk-UA.json

@@ -1549,6 +1549,7 @@
         "localIpPlaceholder": "локальний IP",
         "dialerProxyPlaceholder": "Виберіть вихідний для ланцюжка",
         "dialerProxyHint": "Підключайте цей вихідний через інший вихідний (за тегом), щоб побудувати ланцюжок проксі. Залиште порожнім для прямого підключення.",
+        "targetStrategyHint": "Як розвʼязується домен призначення перед підключенням: AsIs (типово) — надсилається як є, UseIP… — розвʼязання з відкатом, ForceIP… — розвʼязання обовʼязкове.",
         "addressRequired": "Адреса обов'язкова",
         "portRequired": "Порт обов'язковий",
         "optional": "опційно",
@@ -1618,6 +1619,7 @@
         "accountInfo": "Інформація про обліковий запис",
         "outboundStatus": "Статус виходу",
         "sendThrough": "Надіслати через",
+        "targetStrategy": "Стратегія призначення",
         "test": "Тест",
         "testResult": "Результат тесту",
         "testing": "Тестування з'єднання...",

+ 2 - 0
internal/web/translation/vi-VN.json

@@ -1549,6 +1549,7 @@
         "localIpPlaceholder": "IP nội bộ",
         "dialerProxyPlaceholder": "Chọn một outbound để nối chuỗi",
         "dialerProxyHint": "Kết nối outbound này qua một outbound khác (theo tag) để tạo chuỗi proxy. Để trống để kết nối trực tiếp.",
+        "targetStrategyHint": "Cách phân giải tên miền đích trước khi kết nối: AsIs (mặc định) gửi nguyên trạng, UseIP… phân giải kèm dự phòng, ForceIP… bắt buộc phân giải thành công.",
         "addressRequired": "Địa chỉ là bắt buộc",
         "portRequired": "Cổng là bắt buộc",
         "optional": "tùy chọn",
@@ -1618,6 +1619,7 @@
         "accountInfo": "Thông tin tài khoản",
         "outboundStatus": "Trạng thái đầu ra",
         "sendThrough": "Gửi qua",
+        "targetStrategy": "Chiến lược đích",
         "test": "Kiểm tra",
         "testResult": "Kết quả kiểm tra",
         "testing": "Đang kiểm tra kết nối...",

+ 2 - 0
internal/web/translation/zh-CN.json

@@ -1549,6 +1549,7 @@
         "localIpPlaceholder": "本地 IP",
         "dialerProxyPlaceholder": "选择要串联的出站",
         "dialerProxyHint": "让此出站通过另一个出站(按标签)拨号,以建立代理链。留空则直接连接。",
+        "targetStrategyHint": "连接前如何解析目标域名:AsIs(默认)原样发送,UseIP… 解析失败时回退,ForceIP… 必须解析成功。",
         "addressRequired": "地址为必填项",
         "portRequired": "端口为必填项",
         "optional": "可选",
@@ -1618,6 +1619,7 @@
         "accountInfo": "帐户信息",
         "outboundStatus": "出站状态",
         "sendThrough": "发送通过",
+        "targetStrategy": "目标解析策略",
         "test": "测试",
         "testResult": "测试结果",
         "testing": "正在测试连接...",

+ 2 - 0
internal/web/translation/zh-TW.json

@@ -1549,6 +1549,7 @@
         "localIpPlaceholder": "本地 IP",
         "dialerProxyPlaceholder": "選擇要串接的出站",
         "dialerProxyHint": "讓此出站透過另一個出站(以標籤指定)連線,以建立代理鏈。留空則直接連線。",
+        "targetStrategyHint": "連線前如何解析目標網域:AsIs(預設)原樣傳送,UseIP… 解析失敗時回退,ForceIP… 必須解析成功。",
         "addressRequired": "地址為必填",
         "portRequired": "連接埠為必填",
         "optional": "選用",
@@ -1618,6 +1619,7 @@
         "accountInfo": "帳戶資訊",
         "outboundStatus": "出站狀態",
         "sendThrough": "傳送通過",
+        "targetStrategy": "目標解析策略",
         "test": "測試",
         "testResult": "測試結果",
         "testing": "正在測試連接...",