Pārlūkot izejas kodu

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 16 stundas atpakaļ
vecāks
revīzija
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": "正在測試連接...",