2 次代碼提交 99047c0a63 ... 99bc68fa14

作者 SHA1 備註 提交日期
  MHSanaei 99bc68fa14 chore(deps): update project dependencies 5 小時之前
  MHSanaei 62423cacd1 feat(xray): update xray-core to v26.9.30 and adapt panel 2 天之前
共有 44 個文件被更改,包括 2023 次插入 和 721 次删除
  1. 2 2
      .github/workflows/release.yml
  2. 1 1
      DockerInit.sh
  3. 13 13
      docs/package.json
  4. 237 242
      docs/pnpm-lock.yaml
  5. 8 8
      docs/pnpm-workspace.yaml
  6. 238 253
      frontend/package-lock.json
  7. 18 18
      frontend/package.json
  8. 135 23
      frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx
  9. 5 20
      frontend/src/lib/xray/inbound-form-adapter.ts
  10. 62 16
      frontend/src/lib/xray/outbound-form-adapter.ts
  11. 3 0
      frontend/src/lib/xray/outbound-link-parser.ts
  12. 15 0
      frontend/src/lib/xray/stream-wire-normalize.ts
  13. 81 0
      frontend/src/lib/xray/xdns-mask.ts
  14. 1 16
      frontend/src/pages/inbounds/form/protocols/wireguard.tsx
  15. 1 13
      frontend/src/pages/xray/outbounds/protocols/wireguard.tsx
  16. 30 25
      frontend/src/pages/xray/overrides/WarpModal.tsx
  17. 0 2
      frontend/src/schemas/forms/outbound-form.ts
  18. 0 8
      frontend/src/schemas/primitives/options.ts
  19. 3 0
      frontend/src/schemas/protocols/inbound/tun.ts
  20. 0 10
      frontend/src/schemas/protocols/inbound/wireguard.ts
  21. 0 10
      frontend/src/schemas/protocols/outbound/wireguard.ts
  22. 20 3
      frontend/src/test/__snapshots__/finalmask.test.ts.snap
  23. 29 0
      frontend/src/test/finalmask-form.test.tsx
  24. 5 2
      frontend/src/test/golden/fixtures/finalmask/udp-mask.json
  25. 20 0
      frontend/src/test/inbound-from-db.test.ts
  26. 68 0
      frontend/src/test/outbound-form-adapter.test.ts
  27. 74 1
      frontend/src/test/outbound-form-modal.test.tsx
  28. 20 0
      frontend/src/test/outbound-link-parser.test.ts
  29. 1 1
      frontend/src/test/setup.msw.ts
  30. 12 1
      frontend/src/test/warp-change-ip.test.ts
  31. 8 10
      go.mod
  32. 16 20
      go.sum
  33. 234 1
      internal/database/db.go
  34. 147 0
      internal/database/wireguard_domain_strategy_migration_test.go
  35. 116 0
      internal/database/xdns_finalmask_migration_test.go
  36. 3 0
      internal/util/link/outbound.go
  37. 19 0
      internal/util/link/outbound_test.go
  38. 107 0
      internal/util/maskcompat/xdns.go
  39. 101 0
      internal/util/maskcompat/xdns_test.go
  40. 22 0
      internal/web/service/inbound.go
  41. 91 0
      internal/web/service/inbound_finalmask_xdns_test.go
  42. 4 0
      internal/web/service/xray.go
  43. 28 2
      internal/xray/api.go
  44. 25 0
      internal/xray/outbound_validation_test.go

+ 2 - 2
.github/workflows/release.yml

@@ -115,7 +115,7 @@ jobs:
           cd x-ui/bin
 
           # Download dependencies
-          Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.9.9/"
+          Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.9.30/"
           if [ "${{ matrix.platform }}" == "amd64" ]; then
             fetch ${Xray_URL}Xray-linux-64.zip
             unzip Xray-linux-64.zip
@@ -300,7 +300,7 @@ jobs:
           cd x-ui\bin
 
           # Download Xray for Windows
-          $Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.9.9/"
+          $Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.9.30/"
           Invoke-WebRequest @retry -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
           Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
           Remove-Item "Xray-windows-64.zip"

+ 1 - 1
DockerInit.sh

@@ -33,7 +33,7 @@ if [ -z "$MTG_MULTI_VER" ]; then
 fi
 mkdir -p build/bin
 cd build/bin
-curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.9.9/Xray-linux-${ARCH}.zip"
+curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.9.30/Xray-linux-${ARCH}.zip"
 unzip "Xray-linux-${ARCH}.zip"
 rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
 mv xray "xray-linux-${FNAME}"

+ 13 - 13
docs/package.json

@@ -18,34 +18,34 @@
     "test:watch": "vitest"
   },
   "dependencies": {
-    "fumadocs-core": "^16.15.14",
+    "fumadocs-core": "^16.15.18",
     "fumadocs-docgen": "^3.1.1",
-    "fumadocs-mdx": "^15.4.5",
-    "fumadocs-openapi": "^12.0.3",
-    "fumadocs-ui": "^16.15.14",
-    "lucide-react": "^1.48.0",
-    "mermaid": "^12.0.0",
-    "next": "16.3.6",
+    "fumadocs-mdx": "^15.4.6",
+    "fumadocs-openapi": "^12.1.1",
+    "fumadocs-ui": "^16.15.18",
+    "lucide-react": "^1.50.0",
+    "mermaid": "^12.1.0",
+    "next": "16.3.8",
     "next-themes": "^0.4.6",
     "react": "^19.3.0",
     "react-dom": "^19.3.0",
     "react-qr-code": "^2.2.0",
     "tailwind-merge": "^3.7.0",
-    "zbsearch": "4.0.0",
+    "zbsearch": "4.0.1",
     "zod": "^4.6.5"
   },
   "devDependencies": {
     "@tailwindcss/postcss": "^4.3.3",
     "@types/mdx": "^2.0.14",
-    "@types/node": "^26.6.2",
+    "@types/node": "^26.6.4",
     "@types/react": "^19.3.0",
     "@types/react-dom": "^19.3.0",
-    "oxfmt": "0.70.0",
-    "oxlint": "1.85.0",
+    "oxfmt": "0.71.0",
+    "oxlint": "1.86.0",
     "postcss": "^8.5.28",
     "tailwindcss": "^4.3.3",
     "typescript": "7.0.2",
-    "vitest": "^5.0.2"
+    "vitest": "^5.0.3"
   },
-  "packageManager": "pnpm@12.6.0"
+  "packageManager": "pnpm@12.8.1"
 }

文件差異過大導致無法顯示
+ 237 - 242
docs/pnpm-lock.yaml


+ 8 - 8
docs/pnpm-workspace.yaml

@@ -8,16 +8,16 @@ overrides:
   'postcss@<8.5.10': '^8.5.15'
   'sharp@<0.35.0': '^0.35.3'
 minimumReleaseAgeExclude:
-  - '@mermaid-js/[email protected]'
-  - [email protected]
-  - [email protected]
+  - '@mermaid-js/[email protected] || 2.0.1'
+  - [email protected] || 12.1.0
+  - [email protected] || 1.50.0
   - [email protected]
-  - [email protected] || 15.4.1 || 15.4.5
+  - [email protected] || 15.4.1 || 15.4.5 || 15.4.6
   - '@fumadocs/[email protected] || 0.2.9'
-  - '@types/[email protected]'
-  - [email protected] || 16.15.11
-  - [email protected] || 11.4.3 || 12.0.3
-  - [email protected] || 16.15.11
+  - '@types/[email protected] || 26.6.4'
+  - [email protected] || 16.15.11 || 16.15.18
+  - [email protected] || 11.4.3 || 12.0.3 || 12.1.1
+  - [email protected] || 16.15.11 || 16.15.18
   - '@fumadocs/[email protected]'
   - '@fumari/[email protected]'
   - '@fumari/[email protected]'

文件差異過大導致無法顯示
+ 238 - 253
frontend/package-lock.json


+ 18 - 18
frontend/package.json

@@ -39,8 +39,8 @@
     "@codemirror/theme-one-dark": "^6.1.3",
     "@hookform/resolvers": "^5.9.1",
     "@noble/hashes": "^2.4.0",
-    "@tanstack/react-query": "^5.103.2",
-    "@tanstack/react-query-devtools": "^5.103.2",
+    "@tanstack/react-query": "^5.104.1",
+    "@tanstack/react-query-devtools": "^5.104.1",
     "antd": "^6.6.5",
     "codemirror": "^6.0.2",
     "dayjs": "^1.11.23",
@@ -49,41 +49,41 @@
     "persian-calendar-suite": "^1.5.6",
     "react": "^19.3.0",
     "react-dom": "^19.3.0",
-    "react-hook-form": "^7.88.0",
+    "react-hook-form": "^7.89.0",
     "react-i18next": "^17.0.15",
     "react-router": "^8.4.0",
-    "swagger-ui-react": "^5.33.0",
+    "swagger-ui-react": "^5.33.1",
     "uplot": "^1.6.32",
     "zod": "^4.6.5"
   },
   "devDependencies": {
-    "@storybook/addon-a11y": "^10.6.0",
-    "@storybook/addon-docs": "^10.6.0",
-    "@storybook/addon-vitest": "^10.6.0",
-    "@storybook/react-vite": "^10.6.0",
+    "@storybook/addon-a11y": "^10.6.1",
+    "@storybook/addon-docs": "^10.6.1",
+    "@storybook/addon-vitest": "^10.6.1",
+    "@storybook/react-vite": "^10.6.1",
     "@testing-library/dom": "^10.4.2",
     "@testing-library/react": "^16.3.3",
     "@types/react": "^19.3.0",
     "@types/react-dom": "^19.3.0",
     "@types/swagger-ui-react": "^5.18.0",
     "@vitejs/plugin-react": "^6.1.1",
-    "@vitest/browser-playwright": "5.0.2",
-    "@vitest/coverage-v8": "^5.0.2",
+    "@vitest/browser-playwright": "5.0.3",
+    "@vitest/coverage-v8": "^5.0.3",
     "husky": "^9.1.7",
     "jsdom": "^30.1.1",
-    "lint-staged": "^17.5.1",
-    "msw": "^2.15.0",
-    "oxfmt": "0.70.0",
-    "oxlint": "1.85.0",
+    "lint-staged": "^17.6.0",
+    "msw": "^3.0.1",
+    "oxfmt": "0.71.0",
+    "oxlint": "1.86.0",
     "oxlint-tsgolint": "^7.0.2003",
     "playwright": "^1.63.0",
-    "storybook": "^10.6.0",
+    "storybook": "^10.6.1",
     "typescript": "7.0.2",
-    "vite": "8.3.1",
-    "vitest": "^5.0.2"
+    "vite": "8.3.2",
+    "vitest": "^5.0.3"
   },
   "overrides": {
-    "dompurify": "^3.4.11",
+    "dompurify": "^3.4.15",
     "react-copy-to-clipboard": "^5.1.1",
     "react-inspector": "^9.0.0",
     "react-debounce-input": {

+ 135 - 23
frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx

@@ -18,6 +18,7 @@ import type { NamePath } from 'antd/es/form/interface';
 import { RandomUtil } from '@/utils';
 import { activateOnKey } from '@/utils/a11y';
 import { OutboundProtocols, UTLS_FINGERPRINT } from '@/schemas/primitives';
+import { upgradeLegacyXdnsMasks, XDNS_LEGACY_EDNS0 } from '@/lib/xray/xdns-mask';
 
 const UTLS_FINGERPRINT_OPTIONS = Object.values(UTLS_FINGERPRINT).map((value) => ({
   value,
@@ -244,28 +245,34 @@ export default function FinalMaskForm({
 }: FinalMaskFormProps) {
   const base = asPath(name);
 
-  // Migrate legacy TCP mask shapes once on mount so configs saved before
-  // #6334 (fragment ranges) and #6487 (xmc profiles) render in the list UI.
+  // Migrate legacy mask shapes once on mount so configs saved before #6334 (fragment
+  // ranges), #6487 (xmc profiles) and #6718 (xdns objects) render in the list UI.
   const migratedRef = useRef(false);
   useEffect(() => {
     if (migratedRef.current) return;
     migratedRef.current = true;
     const tcp = form.getFieldValue([...base, 'tcp']);
-    if (!Array.isArray(tcp)) return;
-    let anyChanged = false;
-    const next = tcp.map((mask) => {
-      if (!mask || typeof mask !== 'object') return mask;
-      const m = mask as Record<string, unknown>;
-      if (m.type !== 'fragment' && m.type !== 'xmc') return mask;
-      if (!m.settings || typeof m.settings !== 'object') return mask;
-      const settings = m.settings as Record<string, unknown>;
-      const { next: migrated, changed } =
-        m.type === 'fragment' ? migrateFragmentSettings(settings) : migrateXmcSettings(settings);
-      if (!changed) return mask;
-      anyChanged = true;
-      return { ...m, settings: migrated };
-    });
-    if (anyChanged) form.setFieldValue([...base, 'tcp'], next);
+    if (Array.isArray(tcp)) {
+      let anyChanged = false;
+      const next = tcp.map((mask) => {
+        if (!mask || typeof mask !== 'object') return mask;
+        const m = mask as Record<string, unknown>;
+        if (m.type !== 'fragment' && m.type !== 'xmc') return mask;
+        if (!m.settings || typeof m.settings !== 'object') return mask;
+        const settings = m.settings as Record<string, unknown>;
+        const { next: migrated, changed } =
+          m.type === 'fragment' ? migrateFragmentSettings(settings) : migrateXmcSettings(settings);
+        if (!changed) return mask;
+        anyChanged = true;
+        return { ...m, settings: migrated };
+      });
+      if (anyChanged) form.setFieldValue([...base, 'tcp'], next);
+    }
+    const udp = form.getFieldValue([...base, 'udp']);
+    if (Array.isArray(udp)) {
+      const { next, changed } = upgradeLegacyXdnsMasks(udp);
+      if (changed) form.setFieldValue([...base, 'udp'], next);
+    }
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, []);
 
@@ -958,11 +965,7 @@ function UdpMaskItem({
             );
           }
           if (type === 'xdns') {
-            return (
-              <Form.Item label="Domains" name={[fieldName, 'settings', 'domains']}>
-                <Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} />
-              </Form.Item>
-            );
+            return <XdnsSettings udpFieldName={fieldName} />;
           }
           if (type === 'xicmp') {
             return (
@@ -1255,6 +1258,113 @@ function UdpHeaderCustom({
   );
 }
 
+const XDNS_RECORD_TYPE_OPTIONS = [
+  { value: 16, label: 'TXT' },
+  { value: 1, label: 'A' },
+  { value: 28, label: 'AAAA' },
+  { value: 5, label: 'CNAME' },
+];
+
+// Every xdns key needs a registered field: the finalmask watch drops keys without one.
+// Resolvers and extraPoll are read by clients only; a server keeps them for the share link.
+function XdnsSettings({ udpFieldName }: { udpFieldName: number }) {
+  const { t } = useTranslation();
+  return (
+    <>
+      <Form.List name={[udpFieldName, 'settings', 'domains']}>
+        {(domains, { add, remove }) => (
+          <>
+            <Form.Item label="Domains">
+              <Button
+                type="primary"
+                size="small"
+                icon={<PlusOutlined />}
+                aria-label={t('add')}
+                onClick={() => add({ name: '', types: [16], edns0: XDNS_LEGACY_EDNS0 })}
+              />
+            </Form.Item>
+            {domains.map((domain, di) => (
+              <div key={domain.key}>
+                <Divider style={{ margin: 0 }}>
+                  Domain {di + 1}
+                  <DeleteOutlined
+                    className="danger-icon"
+                    role="button"
+                    tabIndex={0}
+                    aria-label={t('remove')}
+                    onClick={() => remove(domain.name)}
+                    onKeyDown={activateOnKey(() => remove(domain.name))}
+                  />
+                </Divider>
+                <Form.Item label="Name" name={[domain.name, 'name']}>
+                  <Input placeholder="t.example.com" />
+                </Form.Item>
+                <Form.Item
+                  label="Record Types"
+                  name={[domain.name, 'types']}
+                  rules={[{ required: true, type: 'array', min: 1 }]}
+                >
+                  <Select mode="multiple" options={XDNS_RECORD_TYPE_OPTIONS} />
+                </Form.Item>
+                <Form.Item label="EDNS0" name={[domain.name, 'edns0']}>
+                  <InputNumber min={512} max={4096} placeholder="off" />
+                </Form.Item>
+                <Form.Item label="Length Limit" name={[domain.name, 'lenLimit']}>
+                  <InputNumber min={0} max={255} placeholder="255" />
+                </Form.Item>
+                <Form.Item label="Label Limit" name={[domain.name, 'labelLimit']}>
+                  <InputNumber min={0} max={63} placeholder="63" />
+                </Form.Item>
+              </div>
+            ))}
+          </>
+        )}
+      </Form.List>
+      <Form.List name={[udpFieldName, 'settings', 'resolvers']}>
+        {(resolvers, { add, remove }) => (
+          <>
+            <Form.Item label="Resolvers (client)">
+              <Button
+                type="primary"
+                size="small"
+                icon={<PlusOutlined />}
+                aria-label={t('add')}
+                onClick={() => add({ type: 'udp', settings: { addr: '' } })}
+              />
+            </Form.Item>
+            {resolvers.map((resolver, ri) => (
+              <Form.Item key={resolver.key} label={`Resolver ${ri + 1}`}>
+                <Space.Compact block>
+                  <Form.Item name={[resolver.name, 'type']} noStyle>
+                    <Select
+                      style={{ width: 80 }}
+                      options={[
+                        { value: 'udp', label: 'UDP' },
+                        { value: 'tcp', label: 'TCP' },
+                      ]}
+                    />
+                  </Form.Item>
+                  <Form.Item name={[resolver.name, 'settings', 'addr']} noStyle>
+                    <Input placeholder="8.8.8.8:53" />
+                  </Form.Item>
+                  <Button
+                    icon={<DeleteOutlined />}
+                    aria-label={t('remove')}
+                    onClick={() => remove(resolver.name)}
+                  />
+                </Space.Compact>
+              </Form.Item>
+            ))}
+          </>
+        )}
+      </Form.List>
+      <Form.Item label="Extra Poll (client)" name={[udpFieldName, 'settings', 'extraPoll']}>
+        <InputNumber min={0} max={3} placeholder="0" />
+      </Form.Item>
+    </>
+  );
+}
+
 function NoiseItems({
   udpFieldName,
   form,
@@ -1352,6 +1462,8 @@ function ItemEditor({
             { value: 'str', label: 'String' },
             { value: 'hex', label: 'Hex' },
             { value: 'base64', label: 'Base64' },
+            // Only the noise mask parses tag expressions (xray-core 26.9.30, #6862).
+            ...(delayMode === 'string' ? [{ value: 'exp', label: 'Expression' }] : []),
           ]}
         />
       </Form.Item>
@@ -1420,7 +1532,7 @@ function ItemEditor({
           }
           return (
             <Form.Item label="Packet" name={[fieldName, 'packet']}>
-              <Input placeholder="binary data" />
+              <Input placeholder={type === 'exp' ? '<b 0d0a0d0a><t><rc 20-40>' : 'binary data'} />
             </Form.Item>
           );
         }}

+ 5 - 20
frontend/src/lib/xray/inbound-form-adapter.ts

@@ -18,7 +18,10 @@ import {
 import type { StreamSettings } from '@/schemas/api/inbound';
 import type { Sniffing } from '@/schemas/primitives';
 import type { z } from 'zod';
-import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
+import {
+  dropEmptyFinalMask,
+  normalizeStreamSettingsForWire,
+} from '@/lib/xray/stream-wire-normalize';
 import { canEnableSniffing } from '@/lib/xray/protocol-capabilities';
 import { tlsCertUsesFiles } from '@/schemas/protocols/security/tls';
 import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
@@ -322,25 +325,7 @@ export function dropLegacyOptionalEmpties(
   if (Array.isArray(fb) && fb.length === 0) delete settings.fallbacks;
 
   if (stream) {
-    // StreamSettings emits `finalmask` only when at least one transport
-    // mask exists (legacy `hasFinalMask`). Drop the whole block when all
-    // sub-fields are empty; otherwise drop only the empty sub-arrays so
-    // the wire payload doesn't carry a stray `"tcp": []` next to a
-    // populated UDP mask list (and vice versa).
-    const fm = stream.finalmask as
-      | { tcp?: unknown[]; udp?: unknown[]; quicParams?: unknown }
-      | undefined;
-    if (fm && typeof fm === 'object') {
-      const hasTcp = Array.isArray(fm.tcp) && fm.tcp.length > 0;
-      const hasUdp = Array.isArray(fm.udp) && fm.udp.length > 0;
-      const hasQuic = fm.quicParams != null;
-      if (!hasTcp && !hasUdp && !hasQuic) {
-        delete stream.finalmask;
-      } else {
-        if (!hasTcp) delete fm.tcp;
-        if (!hasUdp) delete fm.udp;
-      }
-    }
+    dropEmptyFinalMask(stream);
 
     // Hysteria's per-client auth lives in settings.clients[*].auth; the
     // streamSettings.hysteriaSettings.auth slot is a holdover from older

+ 62 - 16
frontend/src/lib/xray/outbound-form-adapter.ts

@@ -1,7 +1,10 @@
 import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
 import { OutboundDomainStrategySchema } from '@/schemas/protocols/outbound';
 import { AmneziaWGOutboundSettingsSchema } from '@/schemas/protocols/outbound';
-import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
+import {
+  dropEmptyFinalMask,
+  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';
@@ -258,20 +261,50 @@ function wireguardFromWire(raw: Raw): WireguardOutboundFormSettings {
     secretKey,
     pubKey,
     address: addressArr.join(','),
-    domainStrategy: ((): WireguardOutboundFormSettings['domainStrategy'] => {
-      const allowed = ['ForceIP', 'ForceIPv4', 'ForceIPv4v6', 'ForceIPv6', 'ForceIPv6v4'];
-      const s = asString(raw.domainStrategy);
-      return (allowed.includes(s) ? s : '') as WireguardOutboundFormSettings['domainStrategy'];
-    })(),
     reserved: reservedArr.join(','),
-    remoteDNS: asArray(raw.remoteDNS)
-      .map((x) => asString(x))
-      .join(','),
+    remoteDNS: isLegacyLocalRemoteDNS(raw.remoteDNS)
+      ? ''
+      : asArray(raw.remoteDNS)
+          .map((x) => asString(x))
+          .join(','),
     peers,
     noKernelTun: asBool(raw.noKernelTun),
   };
 }
 
+// remoteDNS ["local"] is a mode xray-core 26.9.30 removed; the core now panics parsing
+// it as an address, so liftLegacyWireguardStrategy carries it over to targetStrategy.
+function isLegacyLocalRemoteDNS(value: unknown): boolean {
+  const list = asArray(value);
+  return list.length === 1 && list[0] === 'local';
+}
+
+const isAsIs = (strategy: OutboundDomainStrategy | '') => strategy === '' || strategy === 'AsIs';
+
+// xray-core 26.9.30 (#6771) ignores wireguard's settings.domainStrategy: sockopt.domainStrategy
+// now picks the endpoint's family, targetStrategy the targets'. Mirrors the Go seeder.
+function liftLegacyWireguardStrategy(
+  settings: Raw,
+  targetStrategy: OutboundDomainStrategy | '',
+  streamSettings: OutboundStreamFormValues | undefined,
+): { targetStrategy: OutboundDomainStrategy | ''; streamSettings?: OutboundStreamFormValues } {
+  const legacy = targetStrategyFromWire(settings.domainStrategy);
+  const family = legacy.startsWith('ForceIP') && legacy !== 'ForceIP' ? legacy : '';
+  const lifted = family || (isLegacyLocalRemoteDNS(settings.remoteDNS) ? 'ForceIP' : '');
+  let stream = streamSettings;
+  const sockopt = asObject((stream as Raw | undefined)?.sockopt);
+  if (family && isAsIs(targetStrategyFromWire(sockopt.domainStrategy))) {
+    stream = {
+      ...(stream ?? {}),
+      sockopt: { ...sockopt, domainStrategy: family },
+    } as OutboundStreamFormValues;
+  }
+  return {
+    targetStrategy: lifted && isAsIs(targetStrategy) ? lifted : targetStrategy,
+    streamSettings: stream,
+  };
+}
+
 function hysteriaFromWire(raw: Raw): HysteriaOutboundFormSettings {
   return {
     address: asString(raw.address),
@@ -604,15 +637,20 @@ export function rawOutboundToFormValues(raw: RawOutboundRow): OutboundFormValues
       typed = { protocol: 'vless', settings: vlessFromWire(settings) };
   }
 
+  const placed =
+    protocol === 'wireguard'
+      ? liftLegacyWireguardStrategy(settings, targetStrategy, streamSettings)
+      : { targetStrategy, streamSettings };
+
   return {
     ...typed,
     tag,
     sendThrough,
     // The freedom card owns the strategy for freedom, so the shared root field
     // stays empty and cannot disagree with what the card is showing.
-    targetStrategy: protocol === 'freedom' ? '' : targetStrategy,
+    targetStrategy: protocol === 'freedom' ? '' : placed.targetStrategy,
     mux,
-    streamSettings,
+    streamSettings: placed.streamSettings,
   };
 }
 
@@ -715,7 +753,6 @@ function wireguardToWire(s: WireguardOutboundFormSettings) {
           .map((x) => x.trim())
           .filter(Boolean)
       : [],
-    domainStrategy: s.domainStrategy || undefined,
     reserved: s.reserved
       ? s.reserved
           .split(',')
@@ -912,14 +949,23 @@ export function formValuesToWirePayload(values: OutboundFormValues): WireOutboun
     result.targetStrategy = values.targetStrategy;
   }
 
-  // streamSettings emission gates on canEnableStream — non-stream protocols
-  // still emit just `sockopt` if that key is present (legacy behavior).
+  // Non-stream protocols emit only `sockopt`; wireguard also keeps `finalmask`, which the
+  // core dials its peer through (the one non-stream protocol the mask editor renders for).
   if (values.streamSettings) {
     if (STREAM_PROTOCOLS.has(values.protocol)) {
       result.streamSettings = stripUiOnlyStreamFields(values.streamSettings);
     } else {
-      const sockopt = (values.streamSettings as { sockopt?: unknown }).sockopt;
-      if (sockopt) result.streamSettings = { sockopt };
+      const { sockopt, finalmask } = values.streamSettings as {
+        sockopt?: unknown;
+        finalmask?: unknown;
+      };
+      const stream: Raw = {};
+      if (sockopt) stream.sockopt = sockopt;
+      if (values.protocol === 'wireguard' && finalmask && typeof finalmask === 'object') {
+        stream.finalmask = { ...(finalmask as Raw) };
+        dropEmptyFinalMask(stream);
+      }
+      if (Object.keys(stream).length > 0) result.streamSettings = stream;
     }
   }
 

+ 3 - 0
frontend/src/lib/xray/outbound-link-parser.ts

@@ -1,5 +1,7 @@
 import { Base64 } from '@/utils';
 
+import { upgradeLegacyXdnsMasks } from './xdns-mask';
+
 // Focused share-link parser for the OutboundFormModal's link-import
 // helper. Each parser returns a wire-shape outbound record (the same
 // shape OutboundsTab.tsx stores in templateSettings.outbounds[]) or
@@ -279,6 +281,7 @@ function applyFinalMaskParam(stream: Raw, params: URLSearchParams): void {
       const parsed = JSON.parse(fm) as Record<string, unknown>;
       if (parsed && typeof parsed === 'object') {
         sanitizeFinalMaskQuicParams(parsed);
+        if (Array.isArray(parsed.udp)) parsed.udp = upgradeLegacyXdnsMasks(parsed.udp).next;
         stream.finalmask = parsed;
       }
     } catch {

+ 15 - 0
frontend/src/lib/xray/stream-wire-normalize.ts

@@ -323,6 +323,21 @@ export function normalizeSockoptForWire(
   return out;
 }
 
+// Emit `finalmask` only when a mask exists (legacy `hasFinalMask`), and drop an empty
+// sub-array beside a populated one so the payload carries no stray `"tcp": []`.
+export function dropEmptyFinalMask(stream: Record<string, unknown>): void {
+  const fm = stream.finalmask as { tcp?: unknown[]; udp?: unknown[]; quicParams?: unknown };
+  if (!fm || typeof fm !== 'object') return;
+  const hasTcp = Array.isArray(fm.tcp) && fm.tcp.length > 0;
+  const hasUdp = Array.isArray(fm.udp) && fm.udp.length > 0;
+  if (!hasTcp && !hasUdp && fm.quicParams == null) {
+    delete stream.finalmask;
+    return;
+  }
+  if (!hasTcp) delete fm.tcp;
+  if (!hasUdp) delete fm.udp;
+}
+
 export function normalizeStreamSettingsForWire(
   stream: Record<string, unknown>,
   opts: { side: StreamWireSide },

+ 81 - 0
frontend/src/lib/xray/xdns-mask.ts

@@ -0,0 +1,81 @@
+type Raw = Record<string, unknown>;
+
+/** The EDNS0 payload the pre-26.9.30 xdns always negotiated; without it answers cap at 512. */
+export const XDNS_LEGACY_EDNS0 = 1232;
+
+const LEGACY_RECORD_TYPES: Record<string, number> = { '': 16, txt: 16, a: 1, aaaa: 28 };
+
+function legacyDomain(spec: string): Raw | null {
+  let name = spec.trim();
+  let method = '';
+  const colon = name.lastIndexOf(':');
+  if (colon >= 0) {
+    method = name.slice(colon + 1).toLowerCase();
+    name = name.slice(0, colon);
+  }
+  name = name.replace(/^\.+|\.+$/g, '');
+  const type = LEGACY_RECORD_TYPES[method];
+  if (!name || type === undefined) return null;
+  return { name, types: [type], edns0: XDNS_LEGACY_EDNS0 };
+}
+
+// xray-core 26.9.30 (#6718) parses xdns domains/resolvers only as objects. Mirrors
+// internal/util/maskcompat: a bare name becomes TXT, entries the old core refused are dropped.
+export function upgradeLegacyXdnsSettings(settings: Raw): { next: Raw; changed: boolean } {
+  const rawDomains = Array.isArray(settings.domains) ? (settings.domains as unknown[]) : [];
+  const rawResolvers = Array.isArray(settings.resolvers) ? (settings.resolvers as unknown[]) : [];
+  const isLegacy = (v: unknown) => typeof v === 'string';
+  if (!rawDomains.some(isLegacy) && !rawResolvers.some(isLegacy)) {
+    return { next: settings, changed: false };
+  }
+  const domains: unknown[] = [];
+  const listed = new Set<string>();
+  const addDomain = (domain: Raw) => {
+    const key = String(domain.name ?? '').toLowerCase();
+    if (key && listed.has(key)) return;
+    listed.add(key);
+    domains.push(domain);
+  };
+  for (const entry of rawDomains) {
+    if (typeof entry === 'string') {
+      const domain = legacyDomain(entry);
+      if (domain) addDomain(domain);
+    } else if (entry && typeof entry === 'object') {
+      addDomain(entry as Raw);
+    }
+  }
+  const resolvers: unknown[] = [];
+  for (const entry of rawResolvers) {
+    if (typeof entry !== 'string') {
+      resolvers.push(entry);
+      continue;
+    }
+    const sep = entry.indexOf('+udp://');
+    const addr = sep >= 0 ? entry.slice(sep + '+udp://'.length).trim() : '';
+    const domain = sep >= 0 ? legacyDomain(entry.slice(0, sep)) : null;
+    if (!addr || !domain) continue;
+    addDomain(domain);
+    resolvers.push({ type: 'udp', settings: { addr } });
+  }
+  const next: Raw = { ...settings, domains };
+  if (resolvers.length > 0) next.resolvers = resolvers;
+  else delete next.resolvers;
+  return { next, changed: true };
+}
+
+/** Applies upgradeLegacyXdnsSettings to every xdns entry of a finalmask.udp list. */
+export function upgradeLegacyXdnsMasks(udp: unknown[]): { next: unknown[]; changed: boolean } {
+  let changed = false;
+  const next = udp.map((entry) => {
+    const mask = entry as Raw | null;
+    if (!mask || typeof mask !== 'object' || String(mask.type).toLowerCase() !== 'xdns') {
+      return entry;
+    }
+    if (!mask.settings || typeof mask.settings !== 'object') return entry;
+    const upgraded = upgradeLegacyXdnsSettings(mask.settings as Raw);
+    if (!upgraded.changed) return entry;
+    changed = true;
+    return { ...mask, settings: upgraded.next };
+  });
+  return { next, changed };
+}

+ 1 - 16
frontend/src/pages/inbounds/form/protocols/wireguard.tsx

@@ -1,5 +1,5 @@
 import { useTranslation } from 'react-i18next';
-import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
+import { Button, Form, Input, InputNumber, Space, Switch } from 'antd';
 import { ReloadOutlined } from '@ant-design/icons';
 
 import { FormField } from '@/components/form/rhf';
@@ -43,21 +43,6 @@ export default function WireguardFields({ wgPubKey, regenInboundWg }: WireguardF
       >
         <Switch />
       </FormField>
-      <FormField
-        name={['settings', 'domainStrategy']}
-        label={t('pages.xray.wireguard.domainStrategy')}
-      >
-        <Select
-          allowClear
-          options={[
-            { value: 'ForceIP', label: 'ForceIP' },
-            { value: 'ForceIPv4', label: 'ForceIPv4' },
-            { value: 'ForceIPv4v6', label: 'ForceIPv4v6' },
-            { value: 'ForceIPv6', label: 'ForceIPv6' },
-            { value: 'ForceIPv6v4', label: 'ForceIPv6v4' },
-          ]}
-        />
-      </FormField>
     </>
   );
 }

+ 1 - 13
frontend/src/pages/xray/outbounds/protocols/wireguard.tsx

@@ -1,5 +1,5 @@
 import { useTranslation } from 'react-i18next';
-import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
+import { Button, Form, Input, InputNumber, Space, Switch } from 'antd';
 import { DeleteOutlined, MinusOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
 import { useFieldArray, useFormContext } from 'react-hook-form';
 
@@ -7,7 +7,6 @@ import { Wireguard } from '@/utils';
 import { activateOnKey } from '@/utils/a11y';
 import { InputAddon } from '@/components/ui';
 import { FormField } from '@/components/form/rhf';
-import { WireguardDomainStrategy } from '@/schemas/primitives';
 
 function AllowedIPsList({ peerIndex }: { peerIndex: number }) {
   const { t } = useTranslation();
@@ -75,17 +74,6 @@ export default function WireguardFields() {
       <FormField label={t('pages.inbounds.publicKey')} name={['settings', 'pubKey']}>
         <Input disabled />
       </FormField>
-      <FormField
-        label={t('pages.xray.wireguard.domainStrategy')}
-        name={['settings', 'domainStrategy']}
-      >
-        <Select
-          options={[
-            { value: '', label: `(${t('none')})` },
-            ...WireguardDomainStrategy.map((s) => ({ value: s, label: s })),
-          ]}
-        />
-      </FormField>
       <FormField label="MTU" name={['settings', 'mtu']}>
         <InputNumber min={0} />
       </FormField>

+ 30 - 25
frontend/src/pages/xray/overrides/WarpModal.tsx

@@ -65,6 +65,34 @@ function reservedFor(clientId?: string): number[] {
   return out;
 }
 
+export function buildWarpOutbound(
+  data: WarpData | null,
+  config: WarpConfig | null,
+): Record<string, unknown> | null {
+  const cfg = config?.config;
+  if (!cfg?.peers?.length) return null;
+  const peer = cfg.peers[0];
+  return {
+    tag: 'warp',
+    protocol: 'wireguard',
+    // ForceIP may resolve engage.cloudflareclient.com to its AAAA and a half-configured IPv6
+    // host blackholes the handshake silently (#5205); sockopt drives that lookup since 26.9.30.
+    streamSettings: { sockopt: { domainStrategy: 'ForceIPv4v6' } },
+    // Targets inside the tunnel keep the IPv4 preference the removed settings key gave them.
+    targetStrategy: 'ForceIPv4v6',
+    settings: {
+      mtu: 1420,
+      secretKey: data?.private_key,
+      address: addressesFor(cfg.interface?.addresses || {}),
+      reserved: reservedFor(cfg.client_id ?? data?.client_id),
+      peers: [{ publicKey: peer.public_key, endpoint: peer.endpoint?.host }],
+      // Userspace TUN: kernel TUN needs CAP_NET_ADMIN + fwmark routing, fails silently on many
+      // VPS setups, and differs from the connectivity test's path (always noKernelTun=true).
+      noKernelTun: true,
+    },
+  };
+}
+
 export function mergeWarpRotation(
   existing: Record<string, unknown> | undefined,
   data: WarpData | null,
@@ -127,31 +155,8 @@ export default function WarpModal({
 
   const collectConfig = useCallback(
     (data: WarpData | null, config: WarpConfig | null): Record<string, unknown> | null => {
-      const cfg = config?.config;
-      if (!cfg?.peers?.length) return null;
-      const peer = cfg.peers[0];
-      const outbound: Record<string, unknown> = {
-        tag: 'warp',
-        protocol: 'wireguard',
-        settings: {
-          mtu: 1420,
-          secretKey: data?.private_key,
-          address: addressesFor(cfg.interface?.addresses || {}),
-          reserved: reservedFor(cfg.client_id ?? data?.client_id),
-          // Prefer IPv4 with IPv6 fallback: plain ForceIP may pick the AAAA
-          // record for engage.cloudflareclient.com, and a host with
-          // half-configured IPv6 then blackholes the handshake with no error
-          // logged (#5205).
-          domainStrategy: 'ForceIPv4v6',
-          peers: [{ publicKey: peer.public_key, endpoint: peer.endpoint?.host }],
-          // Userspace TUN: kernel TUN needs CAP_NET_ADMIN + fwmark routing and
-          // fails silently on many VPS setups, and it is a different data path
-          // than the panel's connectivity test (which always probes with
-          // noKernelTun=true), so "test ok" and "traffic flows" can disagree.
-          noKernelTun: true,
-        },
-      };
-      setStagedOutbound(outbound);
+      const outbound = buildWarpOutbound(data, config);
+      if (outbound) setStagedOutbound(outbound);
       return outbound;
     },
     [],

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

@@ -13,7 +13,6 @@ import {
   FreedomFragmentSchema,
   FreedomNoiseSchema,
   OutboundDomainStrategySchema,
-  WireguardDomainStrategySchema,
 } from '@/schemas/protocols/outbound';
 
 export const VmessOutboundFormSettingsSchema = z.object({
@@ -105,7 +104,6 @@ export const WireguardOutboundFormSettingsSchema = z.object({
   secretKey: z.string().default(''),
   pubKey: z.string().default(''),
   address: z.string().default(''),
-  domainStrategy: z.union([WireguardDomainStrategySchema, z.literal('')]).default(''),
   reserved: z.string().default(''),
   remoteDNS: z.string().default(''),
   peers: z.array(WireguardOutboundFormPeerSchema).default([]),

+ 0 - 8
frontend/src/schemas/primitives/options.ts

@@ -39,14 +39,6 @@ export const MODE_OPTION = Object.freeze({
   STREAM_ONE: 'stream-one',
 });
 
-export const WireguardDomainStrategy = Object.freeze([
-  'ForceIP',
-  'ForceIPv4',
-  'ForceIPv4v6',
-  'ForceIPv6',
-  'ForceIPv6v4',
-] as const);
-
 export const Address_Port_Strategy = Object.freeze({
   NONE: 'none',
   SRV_PORT_ONLY: 'SrvPortOnly',

+ 3 - 0
frontend/src/schemas/protocols/inbound/tun.ts

@@ -8,5 +8,8 @@ export const TunInboundSettingsSchema = z.object({
   userLevel: z.number().int().min(0).default(0),
   autoSystemRoutingTable: z.array(z.string()).default([]),
   autoOutboundsInterface: z.string().default('auto'),
+  // xray-core 26.9.30: the first acts on Linux, the second ("dns", "misconfigtun") on Windows.
+  autoSystemDnsToGateway: z.boolean().optional(),
+  autoSystemWfpBlockLeak: z.array(z.string()).optional(),
 });
 export type TunInboundSettings = z.infer<typeof TunInboundSettingsSchema>;

+ 0 - 10
frontend/src/schemas/protocols/inbound/wireguard.ts

@@ -1,14 +1,5 @@
 import { z } from 'zod';
 
-export const WireguardDomainStrategySchema = z.enum([
-  'ForceIP',
-  'ForceIPv4',
-  'ForceIPv4v6',
-  'ForceIPv6',
-  'ForceIPv6v4',
-]);
-export type WireguardDomainStrategy = z.infer<typeof WireguardDomainStrategySchema>;
-
 // AntD InputNumber emits null (not undefined) when the user clears it, and
 // the form store hands that null straight to safeParse on submit — a bare
 // .optional() would reject it and block the save.
@@ -68,7 +59,6 @@ export const WireguardInboundSettingsSchema = z.object({
   peers: z.array(WireguardInboundPeerSchema).default([]),
   clients: z.array(WireguardClientSchema).default([]),
   noKernelTun: z.boolean().default(false),
-  domainStrategy: WireguardDomainStrategySchema.optional(),
   // Admin-configurable base subnet new clients are auto-allocated from —
   // mirrors AmneziaWG's settings.server.subnetIp/subnetCidr. Optional and
   // left blank by default: an inbound that never sets this keeps the

+ 0 - 10
frontend/src/schemas/protocols/outbound/wireguard.ts

@@ -1,14 +1,5 @@
 import { z } from 'zod';
 
-export const WireguardDomainStrategySchema = z.enum([
-  'ForceIP',
-  'ForceIPv4',
-  'ForceIPv4v6',
-  'ForceIPv6',
-  'ForceIPv6v4',
-]);
-export type WireguardDomainStrategy = z.infer<typeof WireguardDomainStrategySchema>;
-
 export const WireguardOutboundPeerSchema = z.object({
   publicKey: z.string().min(1),
   preSharedKey: z.string().optional(),
@@ -22,7 +13,6 @@ export const WireguardOutboundSettingsSchema = z.object({
   mtu: z.number().int().min(1).optional(),
   secretKey: z.string().min(1),
   address: z.array(z.string()).default([]),
-  domainStrategy: WireguardDomainStrategySchema.optional(),
   reserved: z.array(z.number().int()).optional(),
   peers: z.array(WireguardOutboundPeerSchema).min(1),
   noKernelTun: z.boolean().default(false),

+ 20 - 3
frontend/src/test/__snapshots__/finalmask.test.ts.snap

@@ -272,11 +272,28 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses udp-mask byte-stably 1`
     {
       "settings": {
         "domains": [
-          "example.com:txt",
-          "example.org:a",
+          {
+            "edns0": 1232,
+            "name": "example.com",
+            "types": [
+              16,
+            ],
+          },
+          {
+            "edns0": 1232,
+            "name": "example.org",
+            "types": [
+              1,
+            ],
+          },
         ],
         "resolvers": [
-          "example.com:txt+udp://1.1.1.1:53",
+          {
+            "settings": {
+              "addr": "1.1.1.1:53",
+            },
+            "type": "udp",
+          },
         ],
       },
       "type": "xdns",

+ 29 - 0
frontend/src/test/finalmask-form.test.tsx

@@ -0,0 +1,29 @@
+import { describe, expect, it } from 'vitest';
+
+import { FinalMaskField } from '@/lib/xray/forms/fields';
+
+import { listSelectOptions, renderWithProviders } from './test-utils';
+
+describe('FinalMaskForm item types', () => {
+  // Only the noise mask parses tag expressions; header-custom items go through the
+  // core's PraseByteSlice, which refuses "exp" and fails the whole config load.
+  it('offers the exp type to noise items only', () => {
+    renderWithProviders(
+      <FinalMaskField
+        network="kcp"
+        protocol="vless"
+        value={{
+          tcp: [],
+          udp: [
+            { type: 'noise', settings: { noise: [{ type: 'array', rand: '1-8', delay: '1-2' }] } },
+            { type: 'header-custom', settings: { client: [{ type: 'array', rand: 1 }] } },
+          ],
+        }}
+      />,
+    );
+
+    // An opened dropdown stays in the DOM, so read header-custom's before noise's.
+    expect(listSelectOptions('finalmask_udp_1_settings_client_0_type')).not.toContain('Expression');
+    expect(listSelectOptions('finalmask_udp_0_settings_noise_0_type')).toContain('Expression');
+  });
+});

+ 5 - 2
frontend/src/test/golden/fixtures/finalmask/udp-mask.json

@@ -48,8 +48,11 @@
     {
       "type": "xdns",
       "settings": {
-        "domains": ["example.com:txt", "example.org:a"],
-        "resolvers": ["example.com:txt+udp://1.1.1.1:53"]
+        "domains": [
+          { "name": "example.com", "types": [16], "edns0": 1232 },
+          { "name": "example.org", "types": [1], "edns0": 1232 }
+        ],
+        "resolvers": [{ "type": "udp", "settings": { "addr": "1.1.1.1:53" } }]
       }
     },
     {

+ 20 - 0
frontend/src/test/inbound-from-db.test.ts

@@ -58,6 +58,26 @@ describe('inboundFromDb', () => {
     expect((inbound.streamSettings as { security?: string })?.security).toBe('tls');
   });
 
+  // The settings parse strips keys the schema does not list, so a TUN option xray-core
+  // 26.9.30 added and an admin entered as JSON would vanish on the next form save.
+  it('keeps the TUN options xray-core 26.9.30 added', () => {
+    const inbound = inboundFromDb({
+      ...BASE_DB_FIELDS,
+      protocol: 'tun',
+      settings: {
+        name: 'xray0',
+        gateway: ['10.0.0.1/16'],
+        autoSystemDnsToGateway: true,
+        autoSystemWfpBlockLeak: ['dns', 'misconfigtun'],
+      },
+      streamSettings: '',
+    });
+    expect(inbound.settings).toMatchObject({
+      autoSystemDnsToGateway: true,
+      autoSystemWfpBlockLeak: ['dns', 'misconfigtun'],
+    });
+  });
+
   it('returns schema-default settings for missing/empty fields without throwing', () => {
     const raw = {
       ...BASE_DB_FIELDS,

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

@@ -312,6 +312,74 @@ describe('outbound-form-adapter: round-trip', () => {
     expect((unset.settings as { remoteDNS?: string[] }).remoteDNS).toBeUndefined();
   });
 
+  // xray-core 26.9.30 ignores wireguard's settings.domainStrategy and panics on remoteDNS
+  // "local"; the endpoint lookup reads sockopt.domainStrategy, in-tunnel targets targetStrategy.
+  it('wireguard lifts the removed domainStrategy and remoteDNS local where the core reads them', () => {
+    const settings = {
+      secretKey: 'YFVmTVCBsLxXJCe4i+jK8PgD3S6vUqfZ4Zl0JVNDfHA=',
+      peers: [{ publicKey: 'pk', endpoint: 'engage.cloudflareclient.com:2408' }],
+    };
+    const warp = formValuesToWirePayload(
+      rawOutboundToFormValues({
+        protocol: 'wireguard',
+        settings: { ...settings, domainStrategy: 'ForceIPv4v6' },
+      }),
+    );
+    expect(warp.targetStrategy).toBe('ForceIPv4v6');
+    expect(warp.streamSettings).toEqual({ sockopt: { domainStrategy: 'ForceIPv4v6' } });
+    expect((warp.settings as Record<string, unknown>).domainStrategy).toBeUndefined();
+
+    const local = formValuesToWirePayload(
+      rawOutboundToFormValues({
+        protocol: 'wireguard',
+        settings: { ...settings, remoteDNS: ['local'] },
+      }),
+    );
+    expect(local.targetStrategy).toBe('ForceIP');
+    expect((local.settings as Record<string, unknown>).remoteDNS).toBeUndefined();
+
+    const admin = formValuesToWirePayload(
+      rawOutboundToFormValues({
+        protocol: 'wireguard',
+        targetStrategy: 'UseIPv6',
+        streamSettings: { sockopt: { domainStrategy: 'UseIPv4' } },
+        settings: { ...settings, domainStrategy: 'ForceIPv6' },
+      }),
+    );
+    expect(admin.targetStrategy).toBe('UseIPv6');
+    expect(admin.streamSettings).toEqual({ sockopt: { domainStrategy: 'UseIPv4' } });
+  });
+
+  // The core dials a wireguard peer through its finalmask (noise "exp" was built for WARP),
+  // so saving through the form must keep the masks next to sockopt instead of dropping them.
+  it('wireguard keeps its finalmask on save and drops an empty one', () => {
+    const settings = {
+      secretKey: 'YFVmTVCBsLxXJCe4i+jK8PgD3S6vUqfZ4Zl0JVNDfHA=',
+      peers: [{ publicKey: 'pk', endpoint: 'engage.cloudflareclient.com:2408' }],
+    };
+    const noise = {
+      type: 'noise',
+      settings: { noise: [{ type: 'exp', packet: '<b 0d0a0d0a><t>', delay: '1-3' }] },
+    };
+    const masked = formValuesToWirePayload(
+      rawOutboundToFormValues({
+        protocol: 'wireguard',
+        settings,
+        streamSettings: { sockopt: { mark: 255 }, finalmask: { tcp: [], udp: [noise] } },
+      }),
+    );
+    expect(masked.streamSettings).toEqual({ sockopt: { mark: 255 }, finalmask: { udp: [noise] } });
+
+    const empty = formValuesToWirePayload(
+      rawOutboundToFormValues({
+        protocol: 'wireguard',
+        settings,
+        streamSettings: { sockopt: { mark: 255 }, finalmask: { tcp: [], udp: [] } },
+      }),
+    );
+    expect(empty.streamSettings).toEqual({ sockopt: { mark: 255 } });
+  });
+
   it('dns rules normalize qType numeric strings, split domains, carry rCode', () => {
     const wire = {
       protocol: 'dns',

+ 74 - 1
frontend/src/test/outbound-form-modal.test.tsx

@@ -1,12 +1,15 @@
 import { describe, it, expect, vi } from 'vitest';
-import { act, fireEvent } from '@testing-library/react';
+import { act, fireEvent, render } from '@testing-library/react';
+import { QueryClientProvider } from '@tanstack/react-query';
 
+import { ThemeProvider } from '@/hooks/useTheme';
 import OutboundFormModal from '@/pages/xray/outbounds/OutboundFormModal';
 import {
   renderWithProviders,
   fieldLabels,
   listSelectOptions,
   chooseSelectOption,
+  makeTestQueryClient,
 } from './test-utils';
 
 function renderModal(outbound: Record<string, unknown> | null = null) {
@@ -149,4 +152,74 @@ describe('OutboundFormModal', () => {
     };
     expect(payload.settings.reverse?.tag).toBe('r1');
   });
+
+  // xray-core 26.9.30 no longer parses xdns's string lists, so the mask editor lifts
+  // them into objects on open rather than saving a config the core would refuse.
+  it('saves a legacy xdns mask in the object shape', async () => {
+    const onConfirm = vi.fn();
+    const queryClient = makeTestQueryClient();
+    const outbound = {
+      protocol: 'vless',
+      tag: 'dns-tunnel',
+      settings: {
+        vnext: [
+          {
+            address: 'example.com',
+            port: 53,
+            users: [{ id: 'c9f0c2d0-0000-4000-8000-000000000000', encryption: 'none' }],
+          },
+        ],
+      },
+      streamSettings: {
+        network: 'kcp',
+        security: 'none',
+        kcpSettings: { mtu: 130, tti: 50 },
+        finalmask: {
+          udp: [{ type: 'xdns', settings: { resolvers: ['t.example.com+udp://8.8.8.8:53'] } }],
+        },
+      },
+    };
+    const tree = (open: boolean) => (
+      <QueryClientProvider client={queryClient}>
+        <ThemeProvider>
+          <OutboundFormModal
+            open={open}
+            outbound={outbound}
+            existingTags={[]}
+            onClose={() => {}}
+            onConfirm={onConfirm}
+          />
+        </ThemeProvider>
+      </QueryClientProvider>
+    );
+    // The panel keeps the modal mounted and flips `open`; mounting it already open lets
+    // the add-mode transport seed replace the edited outbound's kcp stream with tcp.
+    const { rerender } = render(tree(false));
+    rerender(tree(true));
+    await act(async () => {
+      await new Promise((r) => setTimeout(r, 0));
+    });
+
+    const ok = document.querySelector('.ant-modal-footer .ant-btn-primary') as HTMLElement;
+    await act(async () => {
+      fireEvent.click(ok);
+    });
+    await act(async () => {
+      await new Promise((r) => setTimeout(r, 0));
+    });
+
+    expect(onConfirm).toHaveBeenCalledTimes(1);
+    const payload = onConfirm.mock.calls[0][0] as {
+      streamSettings: { finalmask: { udp: Array<{ type: string; settings: unknown }> } };
+    };
+    expect(payload.streamSettings.finalmask.udp).toEqual([
+      {
+        type: 'xdns',
+        settings: {
+          domains: [{ name: 't.example.com', types: [16], edns0: 1232 }],
+          resolvers: [{ type: 'udp', settings: { addr: '8.8.8.8:53' } }],
+        },
+      },
+    ]);
+  });
 });

+ 20 - 0
frontend/src/test/outbound-link-parser.test.ts

@@ -224,6 +224,26 @@ describe('parseVlessLink — XHTTP advanced fields', () => {
 });
 
 describe('parseVlessLink', () => {
+  // A panel older than xray-core 26.9.30 shares xdns in the string lists the core no
+  // longer parses, so an outbound imported verbatim would fail the whole config.
+  it('upgrades a legacy xdns fm= mask to the object shape', () => {
+    const fm = encodeURIComponent(
+      JSON.stringify({
+        udp: [{ type: 'xdns', settings: { resolvers: ['t.example.com+udp://8.8.8.8:53'] } }],
+      }),
+    );
+    const out = parseVlessLink(
+      `vless://11111111-2222-4333-8444-555555555555@srv:53?type=kcp&security=none&fm=${fm}#dns`,
+    );
+    const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as {
+      udp: Array<{ settings: unknown }>;
+    };
+    expect(finalmask.udp[0].settings).toEqual({
+      domains: [{ name: 't.example.com', types: [16], edns0: 1232 }],
+      resolvers: [{ type: 'udp', settings: { addr: '8.8.8.8:53' } }],
+    });
+  });
+
   it('parses a vless:// link with reality', () => {
     const link =
       'vless://[email protected]:443' +

+ 1 - 1
frontend/src/test/setup.msw.ts

@@ -2,6 +2,6 @@ import { afterAll, afterEach, beforeAll } from 'vitest';
 
 import { server } from './msw/server';
 
-beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }));
+beforeAll(() => server.listen({ onUnhandledFrame: 'bypass' }));
 afterEach(() => server.resetHandlers());
 afterAll(() => server.close());

+ 12 - 1
frontend/src/test/warp-change-ip.test.ts

@@ -1,6 +1,6 @@
 import { describe, expect, it } from 'vitest';
 
-import { mergeWarpRotation } from '@/pages/xray/overrides/WarpModal';
+import { buildWarpOutbound, mergeWarpRotation } from '@/pages/xray/overrides/WarpModal';
 
 const clientId = btoa(String.fromCharCode(1, 2, 3));
 
@@ -110,3 +110,14 @@ describe('mergeWarpRotation', () => {
     ]);
   });
 });
+
+describe('buildWarpOutbound', () => {
+  // xray-core 26.9.30 ignores wireguard's settings.domainStrategy, so the IPv4-first
+  // endpoint lookup #5205 depends on has to travel in sockopt, where the core reads it.
+  it('places the IPv4-first strategy where xray-core reads it', () => {
+    const outbound = buildWarpOutbound({ private_key: 'secret' }, rotatedConfig()) ?? {};
+    expect(outbound.streamSettings).toEqual({ sockopt: { domainStrategy: 'ForceIPv4v6' } });
+    expect(outbound.targetStrategy).toBe('ForceIPv4v6');
+    expect(outbound.settings).not.toHaveProperty('domainStrategy');
+  });
+});

+ 8 - 10
go.mod

@@ -9,7 +9,7 @@ require (
 	github.com/gin-gonic/gin v1.12.0
 	github.com/go-ldap/ldap/v3 v3.4.14
 	github.com/go-playground/validator/v10 v10.30.5
-	github.com/goccy/go-json v0.10.6
+	github.com/goccy/go-json v0.11.2
 	github.com/goccy/go-yaml v1.19.2
 	github.com/google/uuid v1.6.0
 	github.com/gorilla/websocket v1.5.3
@@ -22,17 +22,17 @@ require (
 	github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
 	github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af
 	github.com/robfig/cron/v3 v3.0.1
-	github.com/shirou/gopsutil/v4 v4.26.8
+	github.com/shirou/gopsutil/v4 v4.26.9
 	github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
 	github.com/valyala/fasthttp v1.74.0
 	github.com/xlzd/gotp v0.1.0
-	github.com/xtls/xray-core v1.260327.1-0.20260908222543-52a412d9e2f5
+	github.com/xtls/xray-core v1.260327.1-0.20260930074004-b26a91de4f32
 	go.uber.org/atomic v1.12.0
 	golang.org/x/crypto v0.57.0
 	golang.org/x/net v0.59.0
 	golang.org/x/sys v0.48.0
 	golang.org/x/text v0.42.0
-	google.golang.org/grpc v1.85.0-dev.0.20260825072537-93e31b48545e
+	google.golang.org/grpc v1.86.0-dev
 	google.golang.org/protobuf v1.36.12
 	gopkg.in/natefinch/lumberjack.v2 v2.2.1
 	gorm.io/driver/postgres v1.6.3
@@ -44,7 +44,7 @@ require (
 
 require (
 	github.com/Azure/go-ntlmssp v0.1.1 // indirect
-	github.com/andybalholm/brotli v1.2.5 // indirect
+	github.com/andybalholm/brotli v1.2.6 // indirect
 	github.com/apernet/quic-go v0.61.1-0.20260806010916-184d081eef3e // indirect
 	github.com/bytedance/gopkg v0.1.4 // indirect
 	github.com/bytedance/sonic v1.15.4 // indirect
@@ -56,7 +56,7 @@ require (
 	github.com/gin-contrib/sse v1.1.2 // indirect
 	github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
 	github.com/go-ole/go-ole v1.3.0 // indirect
-	github.com/go-playground/locales v0.14.1 // indirect
+	github.com/go-playground/locales v0.14.2 // indirect
 	github.com/go-playground/universal-translator v0.18.2 // indirect
 	github.com/google/btree v1.1.3 // indirect
 	github.com/gorilla/context v1.1.2 // indirect
@@ -84,7 +84,7 @@ require (
 	github.com/modern-go/reflect2 v1.0.2 // indirect
 	github.com/molecule-man/go-brrr v1.1.1 // indirect
 	github.com/pelletier/go-toml/v2 v2.4.3 // indirect
-	github.com/pion/dtls/v3 v3.1.9 // indirect
+	github.com/pion/dtls/v3 v3.1.10 // indirect
 	github.com/pion/logging v0.2.4 // indirect
 	github.com/pion/stun/v3 v3.1.7 // indirect
 	github.com/pion/transport/v4 v4.1.1 // indirect
@@ -94,8 +94,6 @@ require (
 	github.com/quic-go/qpack v0.6.0 // indirect
 	github.com/quic-go/quic-go v0.63.0 // indirect
 	github.com/rogpeppe/go-internal v1.15.0 // indirect
-	github.com/sagernet/sing v0.9.5 // indirect
-	github.com/sagernet/sing-shadowsocks v0.2.9 // indirect
 	github.com/tklauser/go-sysconf v0.4.0 // indirect
 	github.com/tklauser/numcpus v0.12.0 // indirect
 	github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
@@ -116,6 +114,6 @@ require (
 	golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
 	golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 // indirect
 	golang.zx2c4.com/wireguard/windows v1.1.1 // indirect
-	google.golang.org/genproto/googleapis/rpc v0.0.0-20260921155816-b14227669459 // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc // indirect
 	lukechampine.com/blake3 v1.4.1 // indirect
 )

+ 16 - 20
go.sum

@@ -6,8 +6,8 @@ github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktp
 github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
 github.com/amnezia-vpn/amneziawg-go/v3 v3.1.20260828 h1:D8d8gGvwXcTxUIsE4z6F6vjy4/VZddu95vMNtOygh1c=
 github.com/amnezia-vpn/amneziawg-go/v3 v3.1.20260828/go.mod h1:YoPc6qcOZqD7TXZ1xpedD8Sx3aSKsxN05ZqEFmXDNHk=
-github.com/andybalholm/brotli v1.2.5 h1:BSI8V4zmx/3BAn6OKjF1PmfVq7Aoi52AdFsi6bpCx+s=
-github.com/andybalholm/brotli v1.2.5/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
+github.com/andybalholm/brotli v1.2.6 h1:ftYnfj6usCp+UGV5kSJ3+chpMQgU+gJf/AxsUQ52REI=
+github.com/andybalholm/brotli v1.2.6/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
 github.com/apernet/quic-go v0.61.1-0.20260806010916-184d081eef3e h1:5mgtR5gwIgBKMiGI1QdXldZZ+SNor06Nbu1wCBulQBg=
 github.com/apernet/quic-go v0.61.1-0.20260806010916-184d081eef3e/go.mod h1:x7qxEvX6MCVtDuBKHj3E+88+BtrbEMuAL5qGUKItjW8=
 github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
@@ -46,14 +46,14 @@ github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
 github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
 github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
 github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
-github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
-github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
+github.com/go-playground/locales v0.14.2 h1:d8UmcrM6Nip0hfGZKLGpAvZH37XB4TS0xzK9B56YNCY=
+github.com/go-playground/locales v0.14.2/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
 github.com/go-playground/universal-translator v0.18.2 h1:LCsMLC9RzmbUMNUPVYD15dmcjwYAJhmX8mPZRW4rAVU=
 github.com/go-playground/universal-translator v0.18.2/go.mod h1:67VZIMp5lQpDWlnStOct22q1bkdJGJqHghbOtmkawxk=
 github.com/go-playground/validator/v10 v10.30.5 h1:YyCXvVShZbs2Sm3Mb53eNOlhRXctSOzW5QJAouCTZL4=
 github.com/go-playground/validator/v10 v10.30.5/go.mod h1:wEqiaov48pXX1kjhc3Da8y0M0Dtg/BK7gurFBLgwFrQ=
-github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
-github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
+github.com/goccy/go-json v0.11.2 h1:jdZv93Tt4ioR8yW1CoNsvSxrcZlCXAUU1aZXN7gpXUA=
+github.com/goccy/go-json v0.11.2/go.mod h1:3NdmfEkZlB7YI5UFw/qdFKq8XN1aiWR0YyRPWZNQltY=
 github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
 github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
 github.com/golang/mock v1.7.0-rc.1 h1:YojYx61/OLFsiv6Rw1Z96LpldJIy31o+UHmwAUMJ6/U=
@@ -156,8 +156,8 @@ github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3v
 github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
 github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
 github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
-github.com/pion/dtls/v3 v3.1.9 h1:rpeycmLIkc4krpk1IxP7+39o11QdCXbmV9+FGi9yZJ8=
-github.com/pion/dtls/v3 v3.1.9/go.mod h1:iKFQNYrjsN2TiA2YKKMqB9MOZaFpjFULBI/A4sW0eyc=
+github.com/pion/dtls/v3 v3.1.10 h1:HWC+QCZitP/ApADS/6+g7UIw2YmLgoK3CsynnjPJgMo=
+github.com/pion/dtls/v3 v3.1.10/go.mod h1:iKFQNYrjsN2TiA2YKKMqB9MOZaFpjFULBI/A4sW0eyc=
 github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
 github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
 github.com/pion/stun/v3 v3.1.7 h1:uRXMTlGLf89WgItGNyZ6aR5jMTX0NBbybXADpQCzn+E=
@@ -183,12 +183,8 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
 github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
 github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
 github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
-github.com/sagernet/sing v0.9.5 h1:BHaElRqhHhkH747xIkxgsqgRJxkIiuAlpnfwiJnu2aY=
-github.com/sagernet/sing v0.9.5/go.mod h1:K3Owt3xPhHugvlnlPPxZJ/exXdaJfEPOTNorGk4AXjo=
-github.com/sagernet/sing-shadowsocks v0.2.9 h1:Paep5zCszRKsEn8587O0MnhFWKJwDW1Y4zOYYlIxMkM=
-github.com/sagernet/sing-shadowsocks v0.2.9/go.mod h1:TE/Z6401Pi8tgr0nBZcM/xawAI6u3F6TTbz4nH/qw+8=
-github.com/shirou/gopsutil/v4 v4.26.8 h1:YQMTF/1J50B5+Y0vlo1eDRf5DoR7Gk69hY+8wjYkQeo=
-github.com/shirou/gopsutil/v4 v4.26.8/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM=
+github.com/shirou/gopsutil/v4 v4.26.9 h1:CaBo/hBFqvlJoLvAQEKdHdf5GD97/MN2ACfyv63+2Ig=
+github.com/shirou/gopsutil/v4 v4.26.9/go.mod h1:nKH+8wX2zxr/mDbsR+AAy6Qd2z4ZOQ7wfQaEp+yrYmg=
 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -227,8 +223,8 @@ github.com/xlzd/gotp v0.1.0 h1:37blvlKCh38s+fkem+fFh7sMnceltoIEBYTVXyoa5Po=
 github.com/xlzd/gotp v0.1.0/go.mod h1:ndLJ3JKzi3xLmUProq4LLxCuECL93dG9WASNLpHz8qg=
 github.com/xtls/reality v0.0.0-20260921001439-3c98159dee38 h1:Q5KwiMm+WRyw2kwPW4+fIQxNio1CyrWo/eAEaAHxl/Q=
 github.com/xtls/reality v0.0.0-20260921001439-3c98159dee38/go.mod h1:/YQ6FwmAtkDuo8K3ujKQH1Br5eGNFauBipdl1gdiebk=
-github.com/xtls/xray-core v1.260327.1-0.20260908222543-52a412d9e2f5 h1:BsUC2sCXcdVCb09SUh1iWku0ci779t4bUIlKUor1ZRI=
-github.com/xtls/xray-core v1.260327.1-0.20260908222543-52a412d9e2f5/go.mod h1:obbr2WDmr/cpQ/YLe1k0HTULnFXCO0rTWIeSrHoFk3o=
+github.com/xtls/xray-core v1.260327.1-0.20260930074004-b26a91de4f32 h1:Bxo6+07IvdWlqxugsn2/sQrFbR7hjrxRRDMjbOInRho=
+github.com/xtls/xray-core v1.260327.1-0.20260930074004-b26a91de4f32/go.mod h1:FAjlDAzHXXyki7R1BxruCHFx19B3i8TvQGEy+wsqZWU=
 github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
 github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
 github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
@@ -275,10 +271,10 @@ golang.zx2c4.com/wireguard/windows v1.1.1 h1:8/H97U1v1PNDNcBsMZgU3KFuND9MQdTsU2N
 golang.zx2c4.com/wireguard/windows v1.1.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs=
 gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
 gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20260921155816-b14227669459 h1:b0xCahf3FK2m2Cv0p4vTozGPWncCvLfwV86UNg8xWU8=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20260921155816-b14227669459/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc=
-google.golang.org/grpc v1.85.0-dev.0.20260825072537-93e31b48545e h1:ar+feAPij1Znzug45m6AoShUCc5AaR8FLA3I6LZd94k=
-google.golang.org/grpc v1.85.0-dev.0.20260825072537-93e31b48545e/go.mod h1:7aY/KlAHanMAVIWHnk8wloQsdlfwzDz/PP9vCbJG6CU=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc h1:4bNTbnb44EqGVy9HaQxSY2jnafSUdgV3qHtedvNpDKg=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc=
+google.golang.org/grpc v1.86.0-dev h1:FLq7hcApuJeJ70J9+k0FvmXsNCwS8B2/MeAfYdy3GHY=
+google.golang.org/grpc v1.86.0-dev/go.mod h1:Ovl0ECo4xx5r4kn/6d4BPSNB7OIFuu6EAjOzjtVAKaM=
 google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
 google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

+ 234 - 1
internal/database/db.go

@@ -23,6 +23,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/config"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/maskcompat"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/random"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 
@@ -1280,7 +1281,7 @@ func runSeeders(isUsersEmpty bool) error {
 	}
 
 	if empty && isUsersEmpty {
-		seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "UppercaseFreedomFinalRulesFix", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "OutboundRemovedKeysFix", "FreedomDomainStrategyFix", "DNSOutboundLegacyKeysFix", "DNSOutboundQTypeZeroFix", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "ResetIpLimitNoFail2ban"}
+		seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "UppercaseFreedomFinalRulesFix", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "OutboundRemovedKeysFix", "FreedomDomainStrategyFix", "DNSOutboundLegacyKeysFix", "DNSOutboundQTypeZeroFix", "WireguardDomainStrategyFix", "XdnsFinalmaskObjectsFix", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "ResetIpLimitNoFail2ban"}
 		for _, name := range seeders {
 			if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
 				return err
@@ -1415,6 +1416,18 @@ func runSeeders(isUsersEmpty bool) error {
 		}
 	}
 
+	if !slices.Contains(seedersHistory, "WireguardDomainStrategyFix") {
+		if err := migrateWireguardDomainStrategy(); err != nil {
+			return err
+		}
+	}
+
+	if !slices.Contains(seedersHistory, "XdnsFinalmaskObjectsFix") {
+		if err := migrateXdnsFinalmaskObjects(); err != nil {
+			return err
+		}
+	}
+
 	if !slices.Contains(seedersHistory, "NodeInboundsAdopted") {
 		if err := seedNodeInboundsAdopted(); err != nil {
 			return err
@@ -1815,6 +1828,226 @@ var freedomDomainStrategies = map[string]bool{
 	"forceipv6": true, "forceipv4v6": true, "forceipv6v4": true,
 }
 
+func migrateWireguardDomainStrategy() error {
+	var setting model.Setting
+	err := db.Model(model.Setting{}).Where("key = ?", "xrayTemplateConfig").First(&setting).Error
+	if errors.Is(err, gorm.ErrRecordNotFound) {
+		return db.Create(&model.HistoryOfSeeders{SeederName: "WireguardDomainStrategyFix"}).Error
+	}
+	if err != nil {
+		return err
+	}
+
+	updated, changed, rErr := rewriteWireguardDomainStrategy(setting.Value)
+	if rErr != nil {
+		log.Printf("WireguardDomainStrategyFix: skip (invalid xrayTemplateConfig json): %v", rErr)
+		return db.Create(&model.HistoryOfSeeders{SeederName: "WireguardDomainStrategyFix"}).Error
+	}
+
+	return db.Transaction(func(tx *gorm.DB) error {
+		if changed {
+			if err := tx.Model(&model.Setting{}).Where("key = ?", "xrayTemplateConfig").
+				Update("value", updated).Error; err != nil {
+				return err
+			}
+		}
+		return tx.Create(&model.HistoryOfSeeders{SeederName: "WireguardDomainStrategyFix"}).Error
+	})
+}
+
+// rewriteWireguardDomainStrategy splits the settings.domainStrategy xray-core 26.9.30 (#6771)
+// ignores: sockopt.domainStrategy now picks the endpoint's family, targetStrategy the targets'.
+func rewriteWireguardDomainStrategy(raw string) (string, bool, error) {
+	if strings.TrimSpace(raw) == "" {
+		return raw, false, nil
+	}
+	var cfg map[string]any
+	if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
+		return raw, false, err
+	}
+	outbounds, ok := cfg["outbounds"].([]any)
+	if !ok {
+		return raw, false, nil
+	}
+	changed := false
+	for _, ob := range outbounds {
+		obj, ok := ob.(map[string]any)
+		if !ok {
+			continue
+		}
+		if proto, _ := obj["protocol"].(string); !strings.EqualFold(proto, "wireguard") {
+			continue
+		}
+		settings, _ := obj["settings"].(map[string]any)
+		legacy, hasLegacy := settings["domainStrategy"]
+		remoteDNS, _ := settings["remoteDNS"].([]any)
+		localDNS := len(remoteDNS) == 1 && remoteDNS[0] == "local"
+		if !hasLegacy && !localDNS {
+			continue
+		}
+		delete(settings, "domainStrategy")
+		strategy, _ := legacy.(string)
+		if !wireguardFamilyStrategies[strings.ToLower(strategy)] {
+			strategy = ""
+		}
+		if strategy != "" {
+			if sockopt := outboundSockopt(obj, true); !strategyIsSet(sockopt["domainStrategy"]) {
+				sockopt["domainStrategy"] = strategy
+			}
+		}
+		if localDNS {
+			delete(settings, "remoteDNS")
+			if strategy == "" {
+				strategy = "ForceIP"
+			}
+		}
+		if strategy != "" && !strategyIsSet(obj["targetStrategy"]) {
+			obj["targetStrategy"] = strategy
+		}
+		changed = true
+	}
+	if !changed {
+		return raw, false, nil
+	}
+	out, err := json.MarshalIndent(cfg, "", "  ")
+	if err != nil {
+		return raw, false, err
+	}
+	return string(out), true, nil
+}
+
+// wireguardFamilyStrategies are the old wireguard values that pinned an address family;
+// plain ForceIP pinned none, and any other value already failed the old core's load.
+var wireguardFamilyStrategies = map[string]bool{
+	"forceipv4": true, "forceipv6": true, "forceipv4v6": true, "forceipv6v4": true,
+}
+
+func strategyIsSet(value any) bool {
+	s, _ := value.(string)
+	return s != "" && !strings.EqualFold(s, "asis")
+}
+
+// migrateXdnsFinalmaskObjects upgrades every stored xdns mask to the object shape
+// xray-core 26.9.30 requires, wherever the panel keeps a finalmask.
+func migrateXdnsFinalmaskObjects() error {
+	return db.Transaction(func(tx *gorm.DB) error {
+		var inbounds []model.Inbound
+		if err := tx.Select("id", "stream_settings").Find(&inbounds).Error; err != nil {
+			return err
+		}
+		for _, inbound := range inbounds {
+			if updated, changed := upgradeLegacyXdnsJSON(inbound.StreamSettings, streamFinalmask, false); changed {
+				if err := tx.Model(&model.Inbound{}).Where("id = ?", inbound.Id).
+					Update("stream_settings", updated).Error; err != nil {
+					return err
+				}
+			}
+		}
+		var hosts []model.Host
+		if err := tx.Select("id", "final_mask").Find(&hosts).Error; err != nil {
+			return err
+		}
+		for _, host := range hosts {
+			if updated, changed := upgradeLegacyXdnsJSON(host.FinalMask, wholeFinalmask, false); changed {
+				if err := tx.Model(&model.Host{}).Where("id = ?", host.Id).
+					Update("final_mask", updated).Error; err != nil {
+					return err
+				}
+			}
+		}
+		var subs []model.OutboundSubscription
+		if err := tx.Select("id", "last_fetched_outbounds").Find(&subs).Error; err != nil {
+			return err
+		}
+		for _, sub := range subs {
+			if updated, changed := upgradeLegacyXdnsJSON(sub.LastFetchedOutbounds, outboundListFinalmasks, false); changed {
+				if err := tx.Model(&model.OutboundSubscription{}).Where("id = ?", sub.Id).
+					Update("last_fetched_outbounds", updated).Error; err != nil {
+					return err
+				}
+			}
+		}
+		for _, stored := range []struct {
+			key    string
+			locate func(any) []any
+			indent bool
+		}{
+			{"xrayTemplateConfig", templateFinalmasks, true},
+			{"subJsonFinalMask", wholeFinalmask, false},
+		} {
+			var setting model.Setting
+			err := tx.Where("key = ?", stored.key).First(&setting).Error
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				continue
+			}
+			if err != nil {
+				return err
+			}
+			if updated, changed := upgradeLegacyXdnsJSON(setting.Value, stored.locate, stored.indent); changed {
+				if err := tx.Model(&model.Setting{}).Where("key = ?", stored.key).
+					Update("value", updated).Error; err != nil {
+					return err
+				}
+			}
+		}
+		return tx.Create(&model.HistoryOfSeeders{SeederName: "XdnsFinalmaskObjectsFix"}).Error
+	})
+}
+
+// upgradeLegacyXdnsJSON rewrites the finalmasks locate finds in one stored JSON document,
+// leaving the document byte-for-byte alone when nothing in it is legacy.
+func upgradeLegacyXdnsJSON(raw string, locate func(any) []any, indent bool) (string, bool) {
+	if strings.TrimSpace(raw) == "" {
+		return raw, false
+	}
+	var doc any
+	if err := json.Unmarshal([]byte(raw), &doc); err != nil {
+		return raw, false
+	}
+	changed := false
+	for _, mask := range locate(doc) {
+		if maskcompat.UpgradeLegacyXdns(mask) {
+			changed = true
+		}
+	}
+	if !changed {
+		return raw, false
+	}
+	var out []byte
+	var err error
+	if indent {
+		out, err = json.MarshalIndent(doc, "", "  ")
+	} else {
+		out, err = json.Marshal(doc)
+	}
+	if err != nil {
+		return raw, false
+	}
+	return string(out), true
+}
+
+func wholeFinalmask(doc any) []any { return []any{doc} }
+
+func streamFinalmask(doc any) []any {
+	stream, _ := doc.(map[string]any)
+	return []any{stream["finalmask"]}
+}
+
+func outboundListFinalmasks(doc any) []any {
+	list, _ := doc.([]any)
+	finalmasks := make([]any, 0, len(list))
+	for _, entry := range list {
+		obj, _ := entry.(map[string]any)
+		finalmasks = append(finalmasks, streamFinalmask(obj["streamSettings"])...)
+	}
+	return finalmasks
+}
+
+func templateFinalmasks(doc any) []any {
+	cfg, _ := doc.(map[string]any)
+	return append(outboundListFinalmasks(cfg["inbounds"]), outboundListFinalmasks(cfg["outbounds"])...)
+}
+
 // migrateDNSOutboundLegacyKeys rewrites stored dns outbounds once, because the
 // core logs nonIPQuery/blockTypes as deprecated on every config load.
 func migrateDNSOutboundLegacyKeys() error {

+ 147 - 0
internal/database/wireguard_domain_strategy_migration_test.go

@@ -0,0 +1,147 @@
+package database
+
+import (
+	"encoding/json"
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/config"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+const wgTestKeys = `"secretKey":"yAnz5TF+lXXJte14tji3zlMNq+hd2rYUIgJBgB3fBmk=","peers":[{"publicKey":"xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg=","endpoint":"engage.cloudflareclient.com:2408"}]`
+
+func TestRewriteWireguardDomainStrategy(t *testing.T) {
+	tests := []struct {
+		name        string
+		outbound    string
+		wantChanged bool
+		wantRoot    string
+		wantSockopt string
+		wantDNS     bool
+	}{
+		{
+			name:        "the WARP default moves to both places the core now reads",
+			outbound:    `{"protocol":"wireguard","tag":"warp","settings":{"domainStrategy":"ForceIPv4v6",` + wgTestKeys + `}}`,
+			wantChanged: true, wantRoot: "ForceIPv4v6", wantSockopt: "ForceIPv4v6",
+		},
+		{
+			name:        "values the admin already set win over the legacy key",
+			outbound:    `{"protocol":"wireguard","tag":"wg","targetStrategy":"UseIPv6","streamSettings":{"sockopt":{"domainStrategy":"UseIPv4"}},"settings":{"domainStrategy":"forceipv6",` + wgTestKeys + `}}`,
+			wantChanged: true, wantRoot: "UseIPv6", wantSockopt: "UseIPv4",
+		},
+		{
+			name:        "plain ForceIP had no family preference, so only the key goes",
+			outbound:    `{"protocol":"wireguard","tag":"wg","settings":{"domainStrategy":"ForceIP",` + wgTestKeys + `}}`,
+			wantChanged: true,
+		},
+		{
+			name:        "remoteDNS local becomes targetStrategy, which resolves with the built-in DNS",
+			outbound:    `{"protocol":"wireguard","tag":"wg","settings":{"domainStrategy":"ForceIPv4","remoteDNS":["local"],` + wgTestKeys + `}}`,
+			wantChanged: true, wantRoot: "ForceIPv4", wantSockopt: "ForceIPv4",
+		},
+		{
+			name:        "remoteDNS local without a strategy resolves any family",
+			outbound:    `{"protocol":"wireguard","tag":"wg","settings":{"remoteDNS":["local"],` + wgTestKeys + `}}`,
+			wantChanged: true, wantRoot: "ForceIP",
+		},
+		{
+			name:        "a strategy the old core refused is dropped rather than moved",
+			outbound:    `{"protocol":"wireguard","tag":"wg","settings":{"domainStrategy":"UseIPv4",` + wgTestKeys + `}}`,
+			wantChanged: true,
+		},
+		{
+			name:        "IP remoteDNS entries stay",
+			outbound:    `{"protocol":"wireguard","tag":"wg","settings":{"remoteDNS":["1.1.1.1"],` + wgTestKeys + `}}`,
+			wantChanged: false, wantDNS: true,
+		},
+	}
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			updated, changed, err := rewriteWireguardDomainStrategy(`{"outbounds":[` + tc.outbound + `]}`)
+			if err != nil {
+				t.Fatalf("rewrite: %v", err)
+			}
+			if changed != tc.wantChanged {
+				t.Fatalf("changed = %v, want %v", changed, tc.wantChanged)
+			}
+			var cfg struct {
+				Outbounds []json.RawMessage `json:"outbounds"`
+			}
+			if err := json.Unmarshal([]byte(updated), &cfg); err != nil || len(cfg.Outbounds) != 1 {
+				t.Fatalf("rewritten template unreadable (%v): %s", err, updated)
+			}
+			var got struct {
+				TargetStrategy string `json:"targetStrategy"`
+				StreamSettings struct {
+					Sockopt struct {
+						DomainStrategy string `json:"domainStrategy"`
+					} `json:"sockopt"`
+				} `json:"streamSettings"`
+				Settings map[string]any `json:"settings"`
+			}
+			if err := json.Unmarshal(cfg.Outbounds[0], &got); err != nil {
+				t.Fatal(err)
+			}
+			if got.TargetStrategy != tc.wantRoot {
+				t.Errorf("targetStrategy = %q, want %q", got.TargetStrategy, tc.wantRoot)
+			}
+			if got.StreamSettings.Sockopt.DomainStrategy != tc.wantSockopt {
+				t.Errorf("sockopt.domainStrategy = %q, want %q", got.StreamSettings.Sockopt.DomainStrategy, tc.wantSockopt)
+			}
+			if _, kept := got.Settings["domainStrategy"]; kept {
+				t.Errorf("settings.domainStrategy survived the rewrite: %s", cfg.Outbounds[0])
+			}
+			if _, kept := got.Settings["remoteDNS"]; kept != tc.wantDNS {
+				t.Errorf("remoteDNS kept = %v, want %v", kept, tc.wantDNS)
+			}
+			if err := xray.ValidateOutboundConfig(cfg.Outbounds[0]); err != nil {
+				t.Fatalf("xray-core refuses the rewritten outbound: %v", err)
+			}
+		})
+	}
+}
+
+func TestWireguardDomainStrategySeederRewritesStoredTemplateOnce(t *testing.T) {
+	t.Setenv("XUI_DB_FOLDER", t.TempDir())
+	if err := InitDB(config.GetDBPath()); err != nil {
+		if strings.Contains(err.Error(), "CGO_ENABLED=0") {
+			t.Skipf("sqlite needs cgo: %v", err)
+		}
+		t.Fatalf("init db: %v", err)
+	}
+	t.Cleanup(func() { _ = CloseDB() })
+
+	legacy := `{"outbounds":[{"protocol":"wireguard","tag":"warp","settings":{"domainStrategy":"ForceIPv4v6",` + wgTestKeys + `}}]}`
+	seedTemplate(t, legacy)
+	if err := db.Where("seeder_name = ?", "WireguardDomainStrategyFix").
+		Delete(&model.HistoryOfSeeders{}).Error; err != nil {
+		t.Fatalf("clear seeder history: %v", err)
+	}
+
+	if err := runSeeders(false); err != nil {
+		t.Fatalf("runSeeders: %v", err)
+	}
+	var cfg struct {
+		Outbounds []struct {
+			TargetStrategy string         `json:"targetStrategy"`
+			Settings       map[string]any `json:"settings"`
+		} `json:"outbounds"`
+	}
+	if err := json.Unmarshal([]byte(storedTemplate(t)), &cfg); err != nil || len(cfg.Outbounds) != 1 {
+		t.Fatalf("stored template unreadable (%v)", err)
+	}
+	if _, kept := cfg.Outbounds[0].Settings["domainStrategy"]; kept || cfg.Outbounds[0].TargetStrategy != "ForceIPv4v6" {
+		t.Fatalf("stored outbound was not rewritten: %+v", cfg.Outbounds[0])
+	}
+
+	// The history gate keeps a hand-edited template from being rewritten on every restart.
+	seedTemplate(t, legacy)
+	if err := runSeeders(false); err != nil {
+		t.Fatalf("runSeeders: %v", err)
+	}
+	if got := storedTemplate(t); got != legacy {
+		t.Errorf("a completed seeder rewrote the template again: %s", got)
+	}
+}

+ 116 - 0
internal/database/xdns_finalmask_migration_test.go

@@ -0,0 +1,116 @@
+package database
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+const legacyXdnsFinalmask = `{"udp":[{"type":"xdns","settings":{"domains":["t.example.com"]}}]}`
+
+// assertXdnsUpgraded fails unless the finalmask's xdns domains are objects, the only
+// shape xray-core 26.9.30 parses.
+func assertXdnsUpgraded(t *testing.T, where string, finalmask any) {
+	t.Helper()
+	fm, _ := finalmask.(map[string]any)
+	udp, _ := fm["udp"].([]any)
+	if len(udp) != 1 {
+		t.Fatalf("%s: udp masks = %v, want one", where, fm["udp"])
+	}
+	mask, _ := udp[0].(map[string]any)
+	settings, _ := mask["settings"].(map[string]any)
+	domains, _ := settings["domains"].([]any)
+	if len(domains) != 1 {
+		t.Fatalf("%s: domains = %v, want one entry", where, settings["domains"])
+	}
+	domain, ok := domains[0].(map[string]any)
+	if !ok || domain["name"] != "t.example.com" {
+		t.Fatalf("%s: domain = %#v, want an object named t.example.com", where, domains[0])
+	}
+}
+
+func TestXdnsFinalmaskSeederUpgradesEveryStoredMask(t *testing.T) {
+	initMigrateDB(t)
+	ib := seedInboundWithStream(t, "xdns-in", 5353,
+		`{"network":"kcp","security":"none","finalmask":`+legacyXdnsFinalmask+`}`)
+	host := &model.Host{InboundId: ib.Id, Remark: "h", Address: "cdn.example.com", Port: 53, FinalMask: legacyXdnsFinalmask}
+	if err := GetDB().Create(host).Error; err != nil {
+		t.Fatalf("create host: %v", err)
+	}
+	seedTemplate(t, `{"outbounds":[{"protocol":"vless","tag":"dns-tunnel","settings":{},"streamSettings":{"network":"kcp","finalmask":`+legacyXdnsFinalmask+`}}]}`)
+	if err := GetDB().Create(&model.Setting{Key: "subJsonFinalMask", Value: legacyXdnsFinalmask}).Error; err != nil {
+		t.Fatalf("seed subJsonFinalMask: %v", err)
+	}
+	sub := &model.OutboundSubscription{
+		Remark: "donor", Url: "https://donor.example.com/sub",
+		LastFetchedOutbounds: `[{"protocol":"vless","tag":"sub-1","settings":{},"streamSettings":{"network":"kcp","finalmask":` + legacyXdnsFinalmask + `}}]`,
+	}
+	if err := GetDB().Create(sub).Error; err != nil {
+		t.Fatalf("create outbound subscription: %v", err)
+	}
+	if err := GetDB().Where("seeder_name = ?", "XdnsFinalmaskObjectsFix").
+		Delete(&model.HistoryOfSeeders{}).Error; err != nil {
+		t.Fatalf("clear seeder history: %v", err)
+	}
+
+	if err := runSeeders(false); err != nil {
+		t.Fatalf("runSeeders: %v", err)
+	}
+
+	var stored model.Inbound
+	if err := GetDB().First(&stored, ib.Id).Error; err != nil {
+		t.Fatalf("reload inbound: %v", err)
+	}
+	var stream map[string]any
+	if err := json.Unmarshal([]byte(stored.StreamSettings), &stream); err != nil {
+		t.Fatalf("inbound stream is not JSON: %v", err)
+	}
+	assertXdnsUpgraded(t, "inbound stream", stream["finalmask"])
+
+	var storedHost model.Host
+	if err := GetDB().First(&storedHost, host.Id).Error; err != nil {
+		t.Fatalf("reload host: %v", err)
+	}
+	var hostMask any
+	if err := json.Unmarshal([]byte(storedHost.FinalMask), &hostMask); err != nil {
+		t.Fatalf("host finalMask is not JSON: %v", err)
+	}
+	assertXdnsUpgraded(t, "host finalMask", hostMask)
+
+	var template struct {
+		Outbounds []struct {
+			StreamSettings struct {
+				Finalmask any `json:"finalmask"`
+			} `json:"streamSettings"`
+		} `json:"outbounds"`
+	}
+	if err := json.Unmarshal([]byte(storedTemplate(t)), &template); err != nil || len(template.Outbounds) != 1 {
+		t.Fatalf("stored template unreadable (%v)", err)
+	}
+	assertXdnsUpgraded(t, "template outbound", template.Outbounds[0].StreamSettings.Finalmask)
+
+	var subMask model.Setting
+	if err := GetDB().Where("key = ?", "subJsonFinalMask").First(&subMask).Error; err != nil {
+		t.Fatalf("reload subJsonFinalMask: %v", err)
+	}
+	var subFinalmask any
+	if err := json.Unmarshal([]byte(subMask.Value), &subFinalmask); err != nil {
+		t.Fatalf("subJsonFinalMask is not JSON: %v", err)
+	}
+	assertXdnsUpgraded(t, "subJsonFinalMask", subFinalmask)
+
+	var storedSub model.OutboundSubscription
+	if err := GetDB().First(&storedSub, sub.Id).Error; err != nil {
+		t.Fatalf("reload outbound subscription: %v", err)
+	}
+	var cached []struct {
+		StreamSettings struct {
+			Finalmask any `json:"finalmask"`
+		} `json:"streamSettings"`
+	}
+	if err := json.Unmarshal([]byte(storedSub.LastFetchedOutbounds), &cached); err != nil || len(cached) != 1 {
+		t.Fatalf("cached subscription outbounds unreadable (%v)", err)
+	}
+	assertXdnsUpgraded(t, "cached subscription outbound", cached[0].StreamSettings.Finalmask)
+}

+ 3 - 0
internal/util/link/outbound.go

@@ -15,6 +15,8 @@ import (
 	"strconv"
 	"strings"
 	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/util/maskcompat"
 )
 
 // Outbound is the minimal shape we emit for each parsed link.
@@ -766,6 +768,7 @@ func applyFinalMask(stream map[string]any, p url.Values) {
 		var parsed any
 		if json.Unmarshal([]byte(fm), &parsed) == nil {
 			sanitizeFinalMaskQuicParams(parsed)
+			maskcompat.UpgradeLegacyXdns(parsed)
 			stream["finalmask"] = parsed
 		}
 	}

+ 19 - 0
internal/util/link/outbound_test.go

@@ -87,6 +87,25 @@ func TestParseVlessLink_FinalMaskQuicParamsSanitized(t *testing.T) {
 	}
 }
 
+// A panel older than xray-core 26.9.30 shares its xdns mask in the string lists the
+// core no longer parses; imported verbatim, the outbound would fail the whole config.
+func TestParseLink_UpgradesLegacyXdnsFinalMask(t *testing.T) {
+	fm := url.QueryEscape(`{"udp":[{"type":"xdns","settings":{"resolvers":["t.example.com+udp://8.8.8.8:53"]}}]}`)
+	res, err := ParseLink("vless://[email protected]:53?type=kcp&security=none&fm=" + fm + "#dns")
+	if err != nil {
+		t.Fatalf("parse vless with fm: %v", err)
+	}
+	stream, _ := res.Outbound["streamSettings"].(map[string]any)
+	got, err := json.Marshal(stream["finalmask"])
+	if err != nil {
+		t.Fatalf("marshal finalmask: %v", err)
+	}
+	want := `{"udp":[{"settings":{"domains":[{"edns0":1232,"name":"t.example.com","types":[16]}],"resolvers":[{"settings":{"addr":"8.8.8.8:53"},"type":"udp"}]},"type":"xdns"}]}`
+	if string(got) != want {
+		t.Fatalf("imported finalmask\n got: %s\nwant: %s", got, want)
+	}
+}
+
 func TestSanitizeFinalMaskQuicParams_ClampsAndRejects(t *testing.T) {
 	cases := []struct {
 		name string

+ 107 - 0
internal/util/maskcompat/xdns.go

@@ -0,0 +1,107 @@
+package maskcompat
+
+import "strings"
+
+// legacyXdnsEDNS0 is the EDNS0 payload the pre-26.9.30 xdns always negotiated;
+// the object shape makes it opt-in and caps every answer at 512 bytes without it.
+const legacyXdnsEDNS0 = 1232
+
+var legacyXdnsRecordTypes = map[string]int{"": 16, "txt": 16, "a": 1, "aaaa": 28}
+
+// UpgradeLegacyXdns rewrites xdns masks from the string lists xray-core 26.9.30
+// (#6718) no longer parses into its object lists; one legacy mask fails the whole config.
+func UpgradeLegacyXdns(finalmask any) bool {
+	fm, _ := finalmask.(map[string]any)
+	masks, _ := fm["udp"].([]any)
+	changed := false
+	for _, entry := range masks {
+		mask, _ := entry.(map[string]any)
+		if maskType, _ := mask["type"].(string); !strings.EqualFold(maskType, "xdns") {
+			continue
+		}
+		if settings, ok := mask["settings"].(map[string]any); ok && upgradeLegacyXdnsSettings(settings) {
+			changed = true
+		}
+	}
+	return changed
+}
+
+// upgradeLegacyXdnsSettings maps a bare name to TXT, the type legacy clients queried by
+// default, and drops entries the old core refused instead of keeping them to fail again.
+func upgradeLegacyXdnsSettings(settings map[string]any) bool {
+	rawDomains, _ := settings["domains"].([]any)
+	rawResolvers, _ := settings["resolvers"].([]any)
+	if !hasLegacyXdnsEntry(rawDomains) && !hasLegacyXdnsEntry(rawResolvers) {
+		return false
+	}
+	domains := make([]any, 0, len(rawDomains)+len(rawResolvers))
+	listed := map[string]bool{}
+	addDomain := func(domain map[string]any) {
+		key, _ := domain["name"].(string)
+		key = strings.ToLower(key)
+		if key != "" && listed[key] {
+			return
+		}
+		listed[key] = true
+		domains = append(domains, domain)
+	}
+	for _, entry := range rawDomains {
+		switch value := entry.(type) {
+		case map[string]any:
+			addDomain(value)
+		case string:
+			if domain, ok := legacyXdnsDomain(value); ok {
+				addDomain(domain)
+			}
+		}
+	}
+	resolvers := make([]any, 0, len(rawResolvers))
+	for _, entry := range rawResolvers {
+		spec, ok := entry.(string)
+		if !ok {
+			resolvers = append(resolvers, entry)
+			continue
+		}
+		head, addr, found := strings.Cut(spec, "+udp://")
+		addr = strings.TrimSpace(addr)
+		domain, valid := legacyXdnsDomain(head)
+		if !found || addr == "" || !valid {
+			continue
+		}
+		addDomain(domain)
+		resolvers = append(resolvers, map[string]any{
+			"type":     "udp",
+			"settings": map[string]any{"addr": addr},
+		})
+	}
+	settings["domains"] = domains
+	if len(resolvers) > 0 {
+		settings["resolvers"] = resolvers
+	} else {
+		delete(settings, "resolvers")
+	}
+	return true
+}
+
+// legacyXdnsDomain parses the "name[:txt|a|aaaa]" spec both legacy lists used.
+func legacyXdnsDomain(spec string) (map[string]any, bool) {
+	name, method := strings.TrimSpace(spec), ""
+	if i := strings.LastIndex(name, ":"); i >= 0 {
+		name, method = name[:i], strings.ToLower(name[i+1:])
+	}
+	name = strings.Trim(name, ".")
+	recordType, known := legacyXdnsRecordTypes[method]
+	if name == "" || !known {
+		return nil, false
+	}
+	return map[string]any{"name": name, "types": []any{recordType}, "edns0": legacyXdnsEDNS0}, true
+}
+
+func hasLegacyXdnsEntry(values []any) bool {
+	for _, value := range values {
+		if _, ok := value.(string); ok {
+			return true
+		}
+	}
+	return false
+}

+ 101 - 0
internal/util/maskcompat/xdns_test.go

@@ -0,0 +1,101 @@
+package maskcompat
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/xtls/xray-core/infra/conf"
+)
+
+// buildXdnsSettings runs an xdns mask's settings through conf.XDNS, the loader
+// the core calls at startup, so the test's verdict is the core's verdict.
+func buildXdnsSettings(t *testing.T, settings any) error {
+	t.Helper()
+	raw, err := json.Marshal(settings)
+	if err != nil {
+		t.Fatalf("marshal xdns settings: %v", err)
+	}
+	var mask conf.XDNS
+	if err := json.Unmarshal(raw, &mask); err != nil {
+		return err
+	}
+	_, err = mask.Build()
+	return err
+}
+
+func TestUpgradeLegacyXdns(t *testing.T) {
+	tests := []struct {
+		name string
+		mask string
+		want string
+	}{
+		{
+			name: "bare server domain becomes TXT with the legacy EDNS0 size",
+			mask: `{"type":"xdns","settings":{"domains":["t.example.com"]}}`,
+			want: `{"domains":[{"edns0":1232,"name":"t.example.com","types":[16]}]}`,
+		},
+		{
+			name: "method suffixes map to their record types",
+			mask: `{"type":"xdns","settings":{"domains":["a.example.com:a","q.example.com:AAAA","t.example.com:txt"]}}`,
+			want: `{"domains":[{"edns0":1232,"name":"a.example.com","types":[1]},{"edns0":1232,"name":"q.example.com","types":[28]},{"edns0":1232,"name":"t.example.com","types":[16]}]}`,
+		},
+		{
+			name: "client resolver splits into its domain and a udp resolver",
+			mask: `{"type":"XDNS","settings":{"resolvers":["t.example.com:a+udp://8.8.8.8:53"]}}`,
+			want: `{"domains":[{"edns0":1232,"name":"t.example.com","types":[1]}],"resolvers":[{"settings":{"addr":"8.8.8.8:53"},"type":"udp"}]}`,
+		},
+		{
+			name: "a resolver for an already listed domain adds no duplicate",
+			mask: `{"type":"xdns","settings":{"domains":["t.example.com"],"resolvers":["T.example.com+udp://1.1.1.1:53"]}}`,
+			want: `{"domains":[{"edns0":1232,"name":"t.example.com","types":[16]}],"resolvers":[{"settings":{"addr":"1.1.1.1:53"},"type":"udp"}]}`,
+		},
+		{
+			name: "entries the old core refused are dropped",
+			mask: `{"type":"xdns","settings":{"domains":["t.example.com","m.example.com:mx"],"resolvers":["1.1.1.1:53"]}}`,
+			want: `{"domains":[{"edns0":1232,"name":"t.example.com","types":[16]}]}`,
+		},
+	}
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			var mask map[string]any
+			if err := json.Unmarshal([]byte(tc.mask), &mask); err != nil {
+				t.Fatalf("unmarshal mask: %v", err)
+			}
+			if err := buildXdnsSettings(t, mask["settings"]); err == nil {
+				t.Fatal("the core accepted the legacy string shape; the upgrade is no longer needed")
+			}
+			finalmask := map[string]any{"udp": []any{mask}}
+			if !UpgradeLegacyXdns(finalmask) {
+				t.Fatal("UpgradeLegacyXdns reported no change for a legacy mask")
+			}
+			got, err := json.Marshal(mask["settings"])
+			if err != nil {
+				t.Fatalf("marshal upgraded settings: %v", err)
+			}
+			if string(got) != tc.want {
+				t.Fatalf("upgraded settings\n got: %s\nwant: %s", got, tc.want)
+			}
+			if err := buildXdnsSettings(t, mask["settings"]); err != nil {
+				t.Fatalf("the core refuses the upgraded settings: %v", err)
+			}
+		})
+	}
+}
+
+func TestUpgradeLegacyXdnsLeavesCurrentShapeAlone(t *testing.T) {
+	const current = `{"udp":[{"type":"xdns","settings":{"domains":[{"name":"t.example.com","types":[16,28],"edns0":1232}],"resolvers":[{"type":"tcp","settings":{"addr":"8.8.8.8:53"}}],"extraPoll":2}},{"type":"salamander","settings":{"password":"x"}}]}`
+	var finalmask map[string]any
+	if err := json.Unmarshal([]byte(current), &finalmask); err != nil {
+		t.Fatalf("unmarshal finalmask: %v", err)
+	}
+	if UpgradeLegacyXdns(finalmask) {
+		t.Fatal("UpgradeLegacyXdns rewrote a mask that is already in the object shape")
+	}
+	var want map[string]any
+	_ = json.Unmarshal([]byte(current), &want)
+	got, _ := json.Marshal(finalmask)
+	wantJSON, _ := json.Marshal(want)
+	if string(got) != string(wantJSON) {
+		t.Fatalf("finalmask changed\n got: %s\nwant: %s", got, wantJSON)
+	}
+}

+ 22 - 0
internal/web/service/inbound.go

@@ -23,6 +23,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/mtproto"
 	"github.com/mhsanaei/3x-ui/v3/internal/tuic"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/maskcompat"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
 	wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
 
@@ -654,6 +655,27 @@ func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) {
 		return
 	}
 	inbound.StreamSettings = canonicalizeStreamNetworkKey(inbound.StreamSettings)
+	inbound.StreamSettings = canonicalizeLegacyXdnsMasks(inbound.StreamSettings)
+}
+
+// canonicalizeLegacyXdnsMasks stores an xdns mask posted in the pre-26.9.30 string
+// lists in the object shape the core parses, as GetXrayConfig would heal it anyway.
+func canonicalizeLegacyXdnsMasks(streamSettings string) string {
+	if streamSettings == "" {
+		return streamSettings
+	}
+	var stream map[string]any
+	if err := json.Unmarshal([]byte(streamSettings), &stream); err != nil {
+		return streamSettings
+	}
+	if !maskcompat.UpgradeLegacyXdns(stream["finalmask"]) {
+		return streamSettings
+	}
+	out, err := json.MarshalIndent(stream, "", "  ")
+	if err != nil {
+		return streamSettings
+	}
+	return string(out)
 }
 
 // canonicalizeStreamNetworkKey rewrites a streamSettings JSON that names its

+ 91 - 0
internal/web/service/inbound_finalmask_xdns_test.go

@@ -0,0 +1,91 @@
+package service
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+const legacyXdnsStream = `{"network":"kcp","security":"none","kcpSettings":{"mtu":900},"finalmask":{"udp":[{"type":"xdns","settings":{"domains":["t.example.com"]}}]}}`
+
+func firstXdnsDomain(t *testing.T, stream map[string]any) any {
+	t.Helper()
+	finalmask, _ := stream["finalmask"].(map[string]any)
+	udp, _ := finalmask["udp"].([]any)
+	if len(udp) != 1 {
+		t.Fatalf("finalmask.udp = %v, want one mask", finalmask["udp"])
+	}
+	mask, _ := udp[0].(map[string]any)
+	settings, _ := mask["settings"].(map[string]any)
+	domains, _ := settings["domains"].([]any)
+	if len(domains) != 1 {
+		t.Fatalf("xdns domains = %v, want one", settings["domains"])
+	}
+	return domains[0]
+}
+
+// An API client can still post the pre-26.9.30 string lists; stored as sent they would
+// reach the sub links and the form in a shape the core no longer parses.
+func TestAddInbound_StoresXdnsMaskInObjectShape(t *testing.T) {
+	setupConflictDB(t)
+	in := &model.Inbound{
+		Tag: "in-45300-kcp", Enable: true, Listen: "0.0.0.0", Port: 45300, Protocol: model.VLESS,
+		Settings: `{"clients":[],"decryption":"none"}`, StreamSettings: legacyXdnsStream,
+	}
+	if _, _, err := (&InboundService{}).AddInbound(in); err != nil {
+		t.Fatalf("AddInbound: %v", err)
+	}
+
+	var stored model.Inbound
+	if err := database.GetDB().Where("tag = ?", "in-45300-kcp").First(&stored).Error; err != nil {
+		t.Fatalf("reload: %v", err)
+	}
+	var stream map[string]any
+	if err := json.Unmarshal([]byte(stored.StreamSettings), &stream); err != nil {
+		t.Fatalf("stored stream is not JSON: %v", err)
+	}
+	domain, ok := firstXdnsDomain(t, stream).(map[string]any)
+	if !ok || domain["name"] != "t.example.com" {
+		t.Fatalf("stored xdns domain = %#v, want an object named t.example.com", firstXdnsDomain(t, stream))
+	}
+}
+
+// A row that never went through the save path (restored backup, node sync, direct DB
+// edit) must still reach the core in a shape it builds, or it keeps every inbound down.
+func TestGetXrayConfig_UpgradesLegacyXdnsMask(t *testing.T) {
+	setupConflictDB(t)
+	seedInboundConflict(t, "in-45301-kcp", "127.0.0.1", 45301, model.VLESS,
+		legacyXdnsStream, `{"clients":[],"decryption":"none"}`)
+
+	var legacy map[string]any
+	if err := json.Unmarshal([]byte(`{"tag":"in-45301-kcp","listen":"127.0.0.1","port":45301,"protocol":"vless",
+		"settings":{"clients":[],"decryption":"none"},"streamSettings":`+legacyXdnsStream+`}`), &legacy); err != nil {
+		t.Fatalf("decode legacy inbound: %v", err)
+	}
+	if err := buildGoldenInbound(t, legacy); err == nil {
+		t.Fatal("xray-core accepted the legacy xdns lists; the heal is no longer needed")
+	}
+
+	cfg, err := (&XrayService{}).GetXrayConfig()
+	if err != nil {
+		t.Fatalf("GetXrayConfig: %v", err)
+	}
+	for i := range cfg.InboundConfigs {
+		if cfg.InboundConfigs[i].Tag != "in-45301-kcp" {
+			continue
+		}
+		raw, err := json.Marshal(cfg.InboundConfigs[i])
+		if err != nil {
+			t.Fatalf("marshal emitted inbound: %v", err)
+		}
+		var emitted map[string]any
+		if err := json.Unmarshal(raw, &emitted); err != nil {
+			t.Fatalf("decode emitted inbound: %v", err)
+		}
+		assertXrayAccepts(t, "the healed xdns inbound", buildGoldenInbound(t, emitted))
+		return
+	}
+	t.Fatal("inbound in-45301-kcp not found in the generated config")
+}

+ 4 - 0
internal/web/service/xray.go

@@ -17,6 +17,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/maskcompat"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 
 	"go.uber.org/atomic"
@@ -366,6 +367,9 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
 				logger.Warningf("Inbound %q: dropping %d XMC finalmask mask(s) without complete Minecraft profiles — reconfigure them to restore the obfuscation (see XTLS/Xray-core#6487)", inbound.Tag, dropped)
 			}
 
+			// A row that skipped the save path can still carry the pre-26.9.30 xdns lists.
+			maskcompat.UpgradeLegacyXdns(stream["finalmask"])
+
 			// xray-core v26.6.22 (#6258) renamed the XHTTP session keys and
 			// kept no fallback. Lift legacy sessionPlacement/sessionKey onto the
 			// new names here so inbounds stored before the rename keep working

+ 28 - 2
internal/xray/api.go

@@ -9,6 +9,7 @@ import (
 	"fmt"
 	"math"
 	"net"
+	"net/netip"
 	"os"
 	"path/filepath"
 	"regexp"
@@ -182,8 +183,33 @@ func ValidateOutboundConfig(outbound []byte) error {
 	if err := json.Unmarshal(outbound, detour); err != nil {
 		return err
 	}
-	_, err := detour.Build()
-	return err
+	built, err := detour.Build()
+	if err != nil {
+		return err
+	}
+	return validateWireguardRemoteDNS(built.ProxySettings)
+}
+
+// validateWireguardRemoteDNS refuses what conf.Build() lets through but the core feeds to
+// netip.MustParseAddr at startup: a non-IP remoteDNS entry, like "local" before 26.9.30.
+func validateWireguardRemoteDNS(settings *serial.TypedMessage) error {
+	if settings == nil {
+		return nil
+	}
+	instance, err := settings.GetInstance()
+	if err != nil {
+		return nil
+	}
+	device, ok := instance.(*wireguard.DeviceConfig)
+	if !ok || !device.IsClient {
+		return nil
+	}
+	for _, server := range device.DNS {
+		if _, err := netip.ParseAddr(server); err != nil {
+			return common.NewErrorf("wireguard remoteDNS entry %q is not an IP address", server)
+		}
+	}
+	return nil
 }
 
 // AddOutbound adds a new outbound configuration to the Xray core via gRPC.

+ 25 - 0
internal/xray/outbound_validation_test.go

@@ -40,3 +40,28 @@ func TestValidateOutboundConfig_RejectsUnencryptedPublicVless(t *testing.T) {
 		t.Fatalf("a TLS-secured public vless outbound must stay valid, got: %v", err)
 	}
 }
+
+// The core feeds remoteDNS to netip.MustParseAddr when it creates the outbound, so a
+// non-IP entry ("local" until 26.9.30) panics it at startup; conf.Build() lets it through.
+func TestValidateOutboundConfig_RejectsWireguardRemoteDNSThatIsNotAnIP(t *testing.T) {
+	outbound := func(remoteDNS string) []byte {
+		return []byte(`{
+			"protocol": "wireguard",
+			"settings": {
+				"secretKey": "yAnz5TF+lXXJte14tji3zlMNq+hd2rYUIgJBgB3fBmk=",
+				"address": ["10.0.0.2/32"],
+				"peers": [{"publicKey": "xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg=", "endpoint": "162.159.192.1:2408"}],
+				"remoteDNS": ` + remoteDNS + `
+			}
+		}`)
+	}
+	for _, rejected := range []string{`["local"]`, `["1.1.1.1", "dns.google"]`} {
+		err := ValidateOutboundConfig(outbound(rejected))
+		if err == nil || !strings.Contains(err.Error(), "remoteDNS") {
+			t.Errorf("remoteDNS %s: want a remoteDNS refusal, got %v", rejected, err)
+		}
+	}
+	if err := ValidateOutboundConfig(outbound(`["1.1.1.1", "2606:4700:4700::1111"]`)); err != nil {
+		t.Fatalf("IP remoteDNS entries must stay valid, got: %v", err)
+	}
+}

部分文件因文件數量過多而無法顯示