Browse Source

feat(xray): update xray-core to v26.10.10 and adapt panel

Move the three Xray binary pins (DockerInit.sh, both release.yml
fetches) to v26.10.10, in lockstep with the xtls/xray-core module that
82c77111 already bumped to 701af60772cd.

xdns finalmask (#7090): a domain now takes a "names" list instead of a
single "name", and a resolver is an "addrs" list of udp:// or tcp://
URLs instead of {type, settings:{addr}}. The loader ignores the old
keys rather than failing, so every mask the 26.9.30 seeder wrote would
load with no domain and no resolver and the tunnel would carry nothing.
maskcompat.UpgradeLegacyXdns now lifts both the pre-26.9.30 string lists
and the 26.9.30 objects straight to names/addrs, dropping entries the
old loader refused. A new seeder (XdnsFinalmaskNamesAddrsFix) reruns it
over every stored finalmask, and the save path, GetXrayConfig, both
link importers and the mask editor's mount lift go through it. Template
outbounds now get the build-time heal too: a 26.9.30 template pasted
after the seeder passes the save check, since the core loads that shape
without error. The editor edits names and addrs as tag lists and no
longer requires record types, which the core now defaults (TXT on a
client, A/CNAME/TXT/AAAA on a server). The wire format did not change,
but clients on an older core cannot read the new config shape.

Lua scripts (#6823): dns.script, routing.script and dns servers[].id
are new. The xray page parsed the dns block with a strict schema, so
loading it dropped script and id and the next save deleted them; the
schema now keeps both and the DNS server modal carries the id through
an edit. The hot routing apply built routing in-process, and that build
now resolves the script file from the panel's working directory, so a
script beside the xray binary failed every rule change into a full
restart and broke the node bridge. RoutingService cannot change the
script anyway, so the panel no longer resolves it there.

TLS client roots (#7105): the core now verifies against its bundled
Mozilla roots instead of the OS store unless tlsSettings.useSystemCA is
set. The outbound TLS form gets a switch for it, so an outbound to a
server signed by a privately installed CA can opt back in.
MHSanaei 7 hours ago
parent
commit
73a028f52d
37 changed files with 553 additions and 205 deletions
  1. 2 2
      .github/workflows/release.yml
  2. 1 1
      DockerInit.sh
  3. 21 20
      frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx
  4. 49 18
      frontend/src/lib/xray/xdns-mask.ts
  5. 5 0
      frontend/src/pages/xray/dns/DnsServerModal.tsx
  6. 8 1
      frontend/src/pages/xray/outbounds/security/tls.tsx
  7. 2 0
      frontend/src/schemas/dns.ts
  8. 9 6
      frontend/src/test/__snapshots__/finalmask.test.ts.snap
  9. 30 0
      frontend/src/test/dns-server-modal.test.tsx
  10. 3 3
      frontend/src/test/golden/fixtures/finalmask/udp-mask.json
  11. 80 12
      frontend/src/test/outbound-form-modal.test.tsx
  12. 15 10
      frontend/src/test/outbound-link-parser.test.ts
  13. 26 0
      frontend/src/test/use-xray-setting.test.tsx
  14. 7 7
      internal/database/db.go
  15. 12 10
      internal/database/xdns_finalmask_migration_test.go
  16. 4 4
      internal/util/link/outbound_test.go
  17. 89 31
      internal/util/maskcompat/xdns.go
  18. 69 29
      internal/util/maskcompat/xdns_test.go
  19. 2 2
      internal/web/service/inbound.go
  20. 48 24
      internal/web/service/inbound_finalmask_xdns_test.go
  21. 16 12
      internal/web/service/xray.go
  22. 3 3
      internal/web/service/xray_xhttp_session_test.go
  23. 1 0
      internal/web/translation/ar-EG.json
  24. 1 0
      internal/web/translation/en-US.json
  25. 1 0
      internal/web/translation/es-ES.json
  26. 1 0
      internal/web/translation/fa-IR.json
  27. 1 0
      internal/web/translation/id-ID.json
  28. 1 0
      internal/web/translation/ja-JP.json
  29. 1 0
      internal/web/translation/pt-BR.json
  30. 1 0
      internal/web/translation/ru-RU.json
  31. 1 0
      internal/web/translation/tr-TR.json
  32. 1 0
      internal/web/translation/uk-UA.json
  33. 1 0
      internal/web/translation/vi-VN.json
  34. 1 0
      internal/web/translation/zh-CN.json
  35. 1 0
      internal/web/translation/zh-TW.json
  36. 17 10
      internal/xray/api.go
  37. 22 0
      internal/xray/api_routing_test.go

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

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

+ 1 - 1
DockerInit.sh

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

+ 21 - 20
frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx

@@ -246,7 +246,7 @@ export default function FinalMaskForm({
   const base = asPath(name);
   const base = asPath(name);
 
 
   // Migrate legacy mask shapes once on mount so configs saved before #6334 (fragment
   // 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.
+  // ranges), #6487 (xmc profiles) and #7090 (xdns names/addrs) render in the list UI.
   const migratedRef = useRef(false);
   const migratedRef = useRef(false);
   useEffect(() => {
   useEffect(() => {
     if (migratedRef.current) return;
     if (migratedRef.current) return;
@@ -1280,7 +1280,7 @@ function XdnsSettings({ udpFieldName }: { udpFieldName: number }) {
                 size="small"
                 size="small"
                 icon={<PlusOutlined />}
                 icon={<PlusOutlined />}
                 aria-label={t('add')}
                 aria-label={t('add')}
-                onClick={() => add({ name: '', types: [16], edns0: XDNS_LEGACY_EDNS0 })}
+                onClick={() => add({ names: [], edns0: XDNS_LEGACY_EDNS0 })}
               />
               />
             </Form.Item>
             </Form.Item>
             {domains.map((domain, di) => (
             {domains.map((domain, di) => (
@@ -1296,15 +1296,20 @@ function XdnsSettings({ udpFieldName }: { udpFieldName: number }) {
                     onKeyDown={activateOnKey(() => remove(domain.name))}
                     onKeyDown={activateOnKey(() => remove(domain.name))}
                   />
                   />
                 </Divider>
                 </Divider>
-                <Form.Item label="Name" name={[domain.name, 'name']}>
-                  <Input placeholder="t.example.com" />
+                <Form.Item label="Names" name={[domain.name, 'names']}>
+                  <Select
+                    mode="tags"
+                    style={{ width: '100%' }}
+                    tokenSeparators={[',']}
+                    placeholder="t.example.com"
+                  />
                 </Form.Item>
                 </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 label="Record Types" name={[domain.name, 'types']}>
+                  <Select
+                    mode="multiple"
+                    options={XDNS_RECORD_TYPE_OPTIONS}
+                    placeholder="TXT (client), all (server)"
+                  />
                 </Form.Item>
                 </Form.Item>
                 <Form.Item label="EDNS0" name={[domain.name, 'edns0']}>
                 <Form.Item label="EDNS0" name={[domain.name, 'edns0']}>
                   <InputNumber min={512} max={4096} placeholder="off" />
                   <InputNumber min={512} max={4096} placeholder="off" />
@@ -1329,24 +1334,20 @@ function XdnsSettings({ udpFieldName }: { udpFieldName: number }) {
                 size="small"
                 size="small"
                 icon={<PlusOutlined />}
                 icon={<PlusOutlined />}
                 aria-label={t('add')}
                 aria-label={t('add')}
-                onClick={() => add({ type: 'udp', settings: { addr: '' } })}
+                onClick={() => add({ addrs: [] })}
               />
               />
             </Form.Item>
             </Form.Item>
             {resolvers.map((resolver, ri) => (
             {resolvers.map((resolver, ri) => (
               <Form.Item key={resolver.key} label={`Resolver ${ri + 1}`}>
               <Form.Item key={resolver.key} label={`Resolver ${ri + 1}`}>
                 <Space.Compact block>
                 <Space.Compact block>
-                  <Form.Item name={[resolver.name, 'type']} noStyle>
+                  <Form.Item name={[resolver.name, 'addrs']} noStyle>
                     <Select
                     <Select
-                      style={{ width: 80 }}
-                      options={[
-                        { value: 'udp', label: 'UDP' },
-                        { value: 'tcp', label: 'TCP' },
-                      ]}
+                      mode="tags"
+                      style={{ width: '100%' }}
+                      tokenSeparators={[',', ' ']}
+                      placeholder="udp://8.8.8.8:53, tcp://1.1.1.1"
                     />
                     />
                   </Form.Item>
                   </Form.Item>
-                  <Form.Item name={[resolver.name, 'settings', 'addr']} noStyle>
-                    <Input placeholder="8.8.8.8:53" />
-                  </Form.Item>
                   <Button
                   <Button
                     icon={<DeleteOutlined />}
                     icon={<DeleteOutlined />}
                     aria-label={t('remove')}
                     aria-label={t('remove')}

+ 49 - 18
frontend/src/lib/xray/xdns-mask.ts

@@ -5,6 +5,10 @@ export const XDNS_LEGACY_EDNS0 = 1232;
 
 
 const LEGACY_RECORD_TYPES: Record<string, number> = { '': 16, txt: 16, a: 1, aaaa: 28 };
 const LEGACY_RECORD_TYPES: Record<string, number> = { '': 16, txt: 16, a: 1, aaaa: 28 };
 
 
+function isRaw(value: unknown): value is Raw {
+  return !!value && typeof value === 'object' && !Array.isArray(value);
+}
+
 function legacyDomain(spec: string): Raw | null {
 function legacyDomain(spec: string): Raw | null {
   let name = spec.trim();
   let name = spec.trim();
   let method = '';
   let method = '';
@@ -16,46 +20,73 @@ function legacyDomain(spec: string): Raw | null {
   name = name.replace(/^\.+|\.+$/g, '');
   name = name.replace(/^\.+|\.+$/g, '');
   const type = LEGACY_RECORD_TYPES[method];
   const type = LEGACY_RECORD_TYPES[method];
   if (!name || type === undefined) return null;
   if (!name || type === undefined) return null;
-  return { name, types: [type], edns0: XDNS_LEGACY_EDNS0 };
+  return { names: [name], types: [type], edns0: XDNS_LEGACY_EDNS0 };
 }
 }
 
 
-// xray-core 26.9.30 (#6718) parses xdns domains/resolvers only as objects. Mirrors
+/** Folds a 26.9.30 domain's single `name` into the `names` list. */
+function upgradeDomainObject(domain: Raw): Raw {
+  if (!('name' in domain)) return domain;
+  const { name: raw, ...rest } = domain;
+  const name = typeof raw === 'string' ? raw.trim() : '';
+  const names = Array.isArray(rest.names) ? [...(rest.names as unknown[])] : [];
+  if (name && !names.includes(name)) names.unshift(name);
+  return { ...rest, names };
+}
+
+/** A 26.9.30 {type, settings:{addr}} resolver as an addrs URL; that loader took only udp/tcp. */
+function legacyResolverAddr(resolver: Raw): string | null {
+  const kind = typeof resolver.type === 'string' ? resolver.type.trim().toLowerCase() : '';
+  const settings = isRaw(resolver.settings) ? resolver.settings : {};
+  const addr = typeof settings.addr === 'string' ? settings.addr.trim() : '';
+  if ((kind !== 'udp' && kind !== 'tcp') || !addr) return null;
+  return `${kind}://${addr}`;
+}
+
+const isLegacyDomain = (v: unknown) => typeof v === 'string' || (isRaw(v) && 'name' in v);
+const isLegacyResolver = (v: unknown) => typeof v === 'string' || (isRaw(v) && !('addrs' in v));
+
+// xray-core 26.10.10 (#7090) reads xdns only as names/addrs lists. Mirrors
 // internal/util/maskcompat: a bare name becomes TXT, entries the old core refused are dropped.
 // internal/util/maskcompat: a bare name becomes TXT, entries the old core refused are dropped.
 export function upgradeLegacyXdnsSettings(settings: Raw): { next: Raw; changed: boolean } {
 export function upgradeLegacyXdnsSettings(settings: Raw): { next: Raw; changed: boolean } {
   const rawDomains = Array.isArray(settings.domains) ? (settings.domains as unknown[]) : [];
   const rawDomains = Array.isArray(settings.domains) ? (settings.domains as unknown[]) : [];
   const rawResolvers = Array.isArray(settings.resolvers) ? (settings.resolvers 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)) {
+  if (!rawDomains.some(isLegacyDomain) && !rawResolvers.some(isLegacyResolver)) {
     return { next: settings, changed: false };
     return { next: settings, changed: false };
   }
   }
   const domains: unknown[] = [];
   const domains: unknown[] = [];
   const listed = new Set<string>();
   const listed = new Set<string>();
   const addDomain = (domain: Raw) => {
   const addDomain = (domain: Raw) => {
-    const key = String(domain.name ?? '').toLowerCase();
-    if (key && listed.has(key)) return;
-    listed.add(key);
+    const names = Array.isArray(domain.names) ? (domain.names as unknown[]) : [];
+    for (const name of names) if (typeof name === 'string') listed.add(name.toLowerCase());
     domains.push(domain);
     domains.push(domain);
   };
   };
+  const addLegacyDomain = (domain: Raw) => {
+    const [name] = domain.names as string[];
+    if (!listed.has(name.toLowerCase())) addDomain(domain);
+  };
   for (const entry of rawDomains) {
   for (const entry of rawDomains) {
     if (typeof entry === 'string') {
     if (typeof entry === 'string') {
       const domain = legacyDomain(entry);
       const domain = legacyDomain(entry);
-      if (domain) addDomain(domain);
-    } else if (entry && typeof entry === 'object') {
-      addDomain(entry as Raw);
+      if (domain) addLegacyDomain(domain);
+    } else if (isRaw(entry)) {
+      addDomain(upgradeDomainObject(entry));
     }
     }
   }
   }
   const resolvers: unknown[] = [];
   const resolvers: unknown[] = [];
   for (const entry of rawResolvers) {
   for (const entry of rawResolvers) {
-    if (typeof entry !== 'string') {
+    if (typeof entry === 'string') {
+      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;
+      addLegacyDomain(domain);
+      resolvers.push({ addrs: [`udp://${addr}`] });
+    } else if (isRaw(entry) && isLegacyResolver(entry)) {
+      const addr = legacyResolverAddr(entry);
+      if (addr) resolvers.push({ addrs: [addr] });
+    } else {
       resolvers.push(entry);
       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 };
   const next: Raw = { ...settings, domains };
   if (resolvers.length > 0) next.resolvers = resolvers;
   if (resolvers.length > 0) next.resolvers = resolvers;

+ 5 - 0
frontend/src/pages/xray/dns/DnsServerModal.tsx

@@ -32,6 +32,7 @@ interface DnsServerModalProps {
 const STRATEGIES = DnsQueryStrategySchema.options;
 const STRATEGIES = DnsQueryStrategySchema.options;
 
 
 type DnsServerForm = {
 type DnsServerForm = {
+  id: string;
   address: string;
   address: string;
   port: number;
   port: number;
   domains: string[];
   domains: string[];
@@ -50,6 +51,7 @@ type DnsServerForm = {
 
 
 function defaultFormValues(): DnsServerForm {
 function defaultFormValues(): DnsServerForm {
   return {
   return {
+    id: '',
     address: 'localhost',
     address: 'localhost',
     port: 53,
     port: 53,
     domains: [],
     domains: [],
@@ -83,6 +85,7 @@ function valuesFromServer(server: DnsServerValue | null): DnsServerForm {
     skipFallback: data?.skipFallback ?? server.skipFallback ?? false,
     skipFallback: data?.skipFallback ?? server.skipFallback ?? false,
     disableCache: data?.disableCache ?? server.disableCache ?? false,
     disableCache: data?.disableCache ?? server.disableCache ?? false,
     finalQuery: data?.finalQuery ?? server.finalQuery ?? false,
     finalQuery: data?.finalQuery ?? server.finalQuery ?? false,
+    id: data?.id ?? server.id ?? '',
     tag: data?.tag ?? server.tag ?? '',
     tag: data?.tag ?? server.tag ?? '',
     clientIP: data?.clientIP ?? server.clientIP ?? '',
     clientIP: data?.clientIP ?? server.clientIP ?? '',
     serveStale: data?.serveStale ?? server.serveStale ?? false,
     serveStale: data?.serveStale ?? server.serveStale ?? false,
@@ -101,6 +104,7 @@ function valuesToWire(values: DnsServerForm): DnsServerValue {
     values.skipFallback === false &&
     values.skipFallback === false &&
     values.disableCache === false &&
     values.disableCache === false &&
     values.finalQuery === false &&
     values.finalQuery === false &&
+    !values.id &&
     !values.tag &&
     !values.tag &&
     !values.clientIP &&
     !values.clientIP &&
     values.serveStale === false &&
     values.serveStale === false &&
@@ -122,6 +126,7 @@ function valuesToWire(values: DnsServerForm): DnsServerValue {
     timeoutMs: values.timeoutMs,
     timeoutMs: values.timeoutMs,
   };
   };
   if (!isEncryptedDnsAddress(values.address)) out.port = values.port;
   if (!isEncryptedDnsAddress(values.address)) out.port = values.port;
+  if (values.id) out.id = values.id;
   if (values.tag) out.tag = values.tag;
   if (values.tag) out.tag = values.tag;
   if (values.clientIP) out.clientIP = values.clientIP;
   if (values.clientIP) out.clientIP = values.clientIP;
   return out as DnsServerValue;
   return out as DnsServerValue;

+ 8 - 1
frontend/src/pages/xray/outbounds/security/tls.tsx

@@ -1,5 +1,5 @@
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-import { Input, Select } from 'antd';
+import { Input, Select, Switch } from 'antd';
 
 
 import { FormField } from '@/components/form/rhf';
 import { FormField } from '@/components/form/rhf';
 
 
@@ -37,6 +37,13 @@ export default function TlsForm() {
       >
       >
         <Input placeholder="base64 SHA256" />
         <Input placeholder="base64 SHA256" />
       </FormField>
       </FormField>
+      <FormField
+        label={t('pages.xray.outboundForm.useSystemCA')}
+        name={['streamSettings', 'tlsSettings', 'useSystemCA']}
+        valueProp="checked"
+      >
+        <Switch />
+      </FormField>
     </>
     </>
   );
   );
 }
 }

+ 2 - 0
frontend/src/schemas/dns.ts

@@ -14,6 +14,7 @@ export function isEncryptedDnsAddress(address: string): boolean {
 }
 }
 
 
 export const DnsServerObjectInnerSchema = z.object({
 export const DnsServerObjectInnerSchema = z.object({
+  id: z.string().optional(),
   address: z.string(),
   address: z.string(),
   port: PortSchema.optional(),
   port: PortSchema.optional(),
   domains: z.array(z.string()).optional(),
   domains: z.array(z.string()).optional(),
@@ -54,6 +55,7 @@ export const DnsObjectSchema = z.object({
   tag: z.string().optional(),
   tag: z.string().optional(),
   hosts: DnsHostsSchema.optional(),
   hosts: DnsHostsSchema.optional(),
   servers: z.array(DnsServerEntrySchema).optional(),
   servers: z.array(DnsServerEntrySchema).optional(),
+  script: z.string().optional(),
   clientIp: z.string().optional(),
   clientIp: z.string().optional(),
   queryStrategy: DnsQueryStrategySchema.default('UseIP'),
   queryStrategy: DnsQueryStrategySchema.default('UseIP'),
   disableCache: z.boolean().default(false),
   disableCache: z.boolean().default(false),

+ 9 - 6
frontend/src/test/__snapshots__/finalmask.test.ts.snap

@@ -274,14 +274,18 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses udp-mask byte-stably 1`
         "domains": [
         "domains": [
           {
           {
             "edns0": 1232,
             "edns0": 1232,
-            "name": "example.com",
+            "names": [
+              "example.com",
+            ],
             "types": [
             "types": [
               16,
               16,
             ],
             ],
           },
           },
           {
           {
             "edns0": 1232,
             "edns0": 1232,
-            "name": "example.org",
+            "names": [
+              "example.org",
+            ],
             "types": [
             "types": [
               1,
               1,
             ],
             ],
@@ -289,10 +293,9 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses udp-mask byte-stably 1`
         ],
         ],
         "resolvers": [
         "resolvers": [
           {
           {
-            "settings": {
-              "addr": "1.1.1.1:53",
-            },
-            "type": "udp",
+            "addrs": [
+              "udp://1.1.1.1:53",
+            ],
           },
           },
         ],
         ],
       },
       },

+ 30 - 0
frontend/src/test/dns-server-modal.test.tsx

@@ -0,0 +1,30 @@
+import { describe, expect, it, vi } from 'vitest';
+import { act, fireEvent } from '@testing-library/react';
+
+import DnsServerModal from '@/pages/xray/dns/DnsServerModal';
+import { renderWithProviders } from './test-utils';
+
+describe('DnsServerModal', () => {
+  // A Lua dns.script (xray-core 26.10.10) picks servers by id; the modal rebuilds the
+  // server on save, and collapsing an otherwise plain one to its address lost the id.
+  it('keeps the server id through an edit', async () => {
+    const onConfirm = vi.fn();
+    renderWithProviders(
+      <DnsServerModal
+        open
+        server={{ address: '1.1.1.1', id: 'cf', timeoutMs: 4000 }}
+        isEdit
+        onClose={() => {}}
+        onConfirm={onConfirm}
+      />,
+    );
+
+    const ok = document.querySelector('.ant-modal-footer .ant-btn-primary') as HTMLElement;
+    await act(async () => {
+      fireEvent.click(ok);
+    });
+
+    expect(onConfirm).toHaveBeenCalledTimes(1);
+    expect(onConfirm.mock.calls[0][0]).toMatchObject({ address: '1.1.1.1', id: 'cf' });
+  });
+});

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

@@ -49,10 +49,10 @@
       "type": "xdns",
       "type": "xdns",
       "settings": {
       "settings": {
         "domains": [
         "domains": [
-          { "name": "example.com", "types": [16], "edns0": 1232 },
-          { "name": "example.org", "types": [1], "edns0": 1232 }
+          { "names": ["example.com"], "types": [16], "edns0": 1232 },
+          { "names": ["example.org"], "types": [1], "edns0": 1232 }
         ],
         ],
-        "resolvers": [{ "type": "udp", "settings": { "addr": "1.1.1.1:53" } }]
+        "resolvers": [{ "addrs": ["udp://1.1.1.1:53"] }]
       }
       }
     },
     },
     {
     {

+ 80 - 12
frontend/src/test/outbound-form-modal.test.tsx

@@ -24,13 +24,12 @@ function renderModal(outbound: Record<string, unknown> | null = null) {
   );
   );
 }
 }
 
 
-function toggleSockoptsSwitch() {
+function toggleSwitch(label: string) {
   const item = Array.from(document.querySelectorAll('.ant-form-item')).find(
   const item = Array.from(document.querySelectorAll('.ant-form-item')).find(
-    (el) =>
-      (el.querySelector('.ant-form-item-label label')?.textContent ?? '').trim() === 'Sockopts',
+    (el) => (el.querySelector('.ant-form-item-label label')?.textContent ?? '').trim() === label,
   );
   );
   const control = item?.querySelector('.ant-switch');
   const control = item?.querySelector('.ant-switch');
-  if (!control) throw new Error('Sockopts switch not found');
+  if (!control) throw new Error(`${label} switch not found`);
   fireEvent.click(control);
   fireEvent.click(control);
 }
 }
 
 
@@ -74,7 +73,7 @@ describe('OutboundFormModal', () => {
   // sockopt.domainStrategy, so freedom must show only one control for it.
   // sockopt.domainStrategy, so freedom must show only one control for it.
   it('hides the Transport sockopt strategy for freedom', () => {
   it('hides the Transport sockopt strategy for freedom', () => {
     renderModal({ protocol: 'freedom', tag: 'direct', settings: {} });
     renderModal({ protocol: 'freedom', tag: 'direct', settings: {} });
-    toggleSockoptsSwitch();
+    toggleSwitch('Sockopts');
 
 
     expect(fieldLabels()).toContain('Sockopts');
     expect(fieldLabels()).toContain('Sockopts');
     expect(fieldLabels()).not.toContain('Domain Strategy');
     expect(fieldLabels()).not.toContain('Domain Strategy');
@@ -83,7 +82,7 @@ describe('OutboundFormModal', () => {
 
 
   it('keeps the Transport sockopt strategy for protocols without a card field', () => {
   it('keeps the Transport sockopt strategy for protocols without a card field', () => {
     renderModal({ protocol: 'vless', tag: 'proxy', settings: {} });
     renderModal({ protocol: 'vless', tag: 'proxy', settings: {} });
-    toggleSockoptsSwitch();
+    toggleSwitch('Sockopts');
 
 
     expect(fieldLabels()).toContain('Domain Strategy');
     expect(fieldLabels()).toContain('Domain Strategy');
   });
   });
@@ -153,9 +152,9 @@ describe('OutboundFormModal', () => {
     expect(payload.settings.reverse?.tag).toBe('r1');
     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 () => {
+  // xray-core 26.10.10 reads a 26.9.30 xdns mask as no domain at all, so the mask editor
+  // lifts it to names/addrs on open and must keep those keys registered through the save.
+  it('saves a legacy xdns mask in the names/addrs shape', async () => {
     const onConfirm = vi.fn();
     const onConfirm = vi.fn();
     const queryClient = makeTestQueryClient();
     const queryClient = makeTestQueryClient();
     const outbound = {
     const outbound = {
@@ -175,7 +174,15 @@ describe('OutboundFormModal', () => {
         security: 'none',
         security: 'none',
         kcpSettings: { mtu: 130, tti: 50 },
         kcpSettings: { mtu: 130, tti: 50 },
         finalmask: {
         finalmask: {
-          udp: [{ type: 'xdns', settings: { resolvers: ['t.example.com+udp://8.8.8.8:53'] } }],
+          udp: [
+            {
+              type: 'xdns',
+              settings: {
+                domains: [{ name: 't.example.com', types: [16], edns0: 1232 }],
+                resolvers: [{ type: 'udp', settings: { addr: '8.8.8.8:53' } }],
+              },
+            },
+          ],
         },
         },
       },
       },
     };
     };
@@ -216,10 +223,71 @@ describe('OutboundFormModal', () => {
       {
       {
         type: 'xdns',
         type: 'xdns',
         settings: {
         settings: {
-          domains: [{ name: 't.example.com', types: [16], edns0: 1232 }],
-          resolvers: [{ type: 'udp', settings: { addr: '8.8.8.8:53' } }],
+          domains: [{ names: ['t.example.com'], types: [16], edns0: 1232 }],
+          resolvers: [{ addrs: ['udp://8.8.8.8:53'] }],
         },
         },
       },
       },
     ]);
     ]);
   });
   });
+
+  // xray-core 26.10.10 verifies TLS against its bundled roots, not the OS store, so an
+  // outbound to a server signed by a privately installed CA needs useSystemCA set.
+  it('saves useSystemCA from the TLS switch', async () => {
+    const onConfirm = vi.fn();
+    const queryClient = makeTestQueryClient();
+    const outbound = {
+      protocol: 'vless',
+      tag: 'tls-out',
+      settings: {
+        vnext: [
+          {
+            address: 'example.com',
+            port: 443,
+            users: [{ id: 'c9f0c2d0-0000-4000-8000-000000000000', encryption: 'none' }],
+          },
+        ],
+      },
+      streamSettings: {
+        network: 'tcp',
+        security: 'tls',
+        tlsSettings: { serverName: 'example.com' },
+      },
+    };
+    const tree = (open: boolean) => (
+      <QueryClientProvider client={queryClient}>
+        <ThemeProvider>
+          <OutboundFormModal
+            open={open}
+            outbound={outbound}
+            existingTags={[]}
+            onClose={() => {}}
+            onConfirm={onConfirm}
+          />
+        </ThemeProvider>
+      </QueryClientProvider>
+    );
+    const { rerender } = render(tree(false));
+    rerender(tree(true));
+    await act(async () => {
+      await new Promise((r) => setTimeout(r, 0));
+    });
+
+    toggleSwitch('Use system CA');
+    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: { tlsSettings: { serverName?: string; useSystemCA?: boolean } };
+    };
+    expect(payload.streamSettings.tlsSettings).toMatchObject({
+      serverName: 'example.com',
+      useSystemCA: true,
+    });
+  });
 });
 });

+ 15 - 10
frontend/src/test/outbound-link-parser.test.ts

@@ -225,14 +225,19 @@ describe('parseVlessLink — XHTTP advanced fields', () => {
 });
 });
 
 
 describe('parseVlessLink', () => {
 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'] } }],
-      }),
-    );
+  // Older panels share xdns as string lists (pre-26.9.30, a load error) or as name/typed
+  // resolver objects (26.9.30) that xray-core 26.10.10 reads as no domain at all.
+  it.each([
+    ['pre-26.9.30 string lists', { resolvers: ['t.example.com+udp://8.8.8.8:53'] }],
+    [
+      '26.9.30 objects',
+      {
+        domains: [{ name: 't.example.com', types: [16], edns0: 1232 }],
+        resolvers: [{ type: 'udp', settings: { addr: '8.8.8.8:53' } }],
+      },
+    ],
+  ])('upgrades a %s xdns fm= mask to names/addrs', (_shape, settings) => {
+    const fm = encodeURIComponent(JSON.stringify({ udp: [{ type: 'xdns', settings }] }));
     const out = parseVlessLink(
     const out = parseVlessLink(
       `vless://11111111-2222-4333-8444-555555555555@srv:53?type=kcp&security=none&fm=${fm}#dns`,
       `vless://11111111-2222-4333-8444-555555555555@srv:53?type=kcp&security=none&fm=${fm}#dns`,
     );
     );
@@ -240,8 +245,8 @@ describe('parseVlessLink', () => {
       udp: Array<{ settings: unknown }>;
       udp: Array<{ settings: unknown }>;
     };
     };
     expect(finalmask.udp[0].settings).toEqual({
     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' } }],
+      domains: [{ names: ['t.example.com'], types: [16], edns0: 1232 }],
+      resolvers: [{ addrs: ['udp://8.8.8.8:53'] }],
     });
     });
   });
   });
 
 

+ 26 - 0
frontend/src/test/use-xray-setting.test.tsx

@@ -49,6 +49,32 @@ describe('useXraySetting', () => {
     expect(result.current.xraySetting).toBe('{"outbounds":[]}');
     expect(result.current.xraySetting).toBe('{"outbounds":[]}');
   });
   });
 
 
+  // xray-core 26.10.10 runs a Lua dns.script that addresses servers by id; stripped
+  // on load, the next template save would silently delete both.
+  it('keeps the dns script and server ids through the load', async () => {
+    const payload = xrayPayload({
+      xraySetting: {
+        dns: { script: 'dns.lua', servers: [{ address: '1.1.1.1', port: 53, id: 'cf' }] },
+      },
+    });
+    vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => {
+      if (url === '/panel/api/xray/') return new Msg(true, '', JSON.stringify(payload));
+      return new Msg(true, '');
+    });
+    const queryClient = makeTestQueryClient();
+    const wrapper = ({ children }: { children: ReactNode }) => (
+      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
+    );
+    const { result } = renderHook(() => useXraySetting(), { wrapper });
+
+    await waitFor(() => expect(result.current.fetched).toBe(true));
+    const loaded = JSON.parse(result.current.xraySetting) as {
+      dns: { script?: string; servers: Array<Record<string, unknown>> };
+    };
+    expect(loaded.dns.script).toBe('dns.lua');
+    expect(loaded.dns.servers[0].id).toBe('cf');
+  });
+
   it('keeps the outbound test URL input empty when it is cleared', async () => {
   it('keeps the outbound test URL input empty when it is cleared', async () => {
     const payload = xrayPayload({ outboundTestUrl: 'https://www.google.com/generate_204' });
     const payload = xrayPayload({ outboundTestUrl: 'https://www.google.com/generate_204' });
     vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => {
     vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => {

+ 7 - 7
internal/database/db.go

@@ -1282,7 +1282,7 @@ func runSeeders(isUsersEmpty bool) error {
 	}
 	}
 
 
 	if empty && isUsersEmpty {
 	if empty && isUsersEmpty {
-		seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "UppercaseFreedomFinalRulesFix", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "OutboundRemovedKeysFix", "FreedomDomainStrategyFix", "DNSOutboundLegacyKeysFix", "DNSOutboundQTypeZeroFix", "WireguardDomainStrategyFix", "XdnsFinalmaskObjectsFix", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "ResetIpLimitNoFail2ban"}
+		seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "UppercaseFreedomFinalRulesFix", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "OutboundRemovedKeysFix", "FreedomDomainStrategyFix", "DNSOutboundLegacyKeysFix", "DNSOutboundQTypeZeroFix", "WireguardDomainStrategyFix", "XdnsFinalmaskNamesAddrsFix", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "ResetIpLimitNoFail2ban"}
 		for _, name := range seeders {
 		for _, name := range seeders {
 			if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
 			if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
 				return err
 				return err
@@ -1423,8 +1423,8 @@ func runSeeders(isUsersEmpty bool) error {
 		}
 		}
 	}
 	}
 
 
-	if !slices.Contains(seedersHistory, "XdnsFinalmaskObjectsFix") {
-		if err := migrateXdnsFinalmaskObjects(); err != nil {
+	if !slices.Contains(seedersHistory, "XdnsFinalmaskNamesAddrsFix") {
+		if err := migrateXdnsFinalmaskShape(); err != nil {
 			return err
 			return err
 		}
 		}
 	}
 	}
@@ -1928,9 +1928,9 @@ func strategyIsSet(value any) bool {
 	return s != "" && !strings.EqualFold(s, "asis")
 	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 {
+// migrateXdnsFinalmaskShape upgrades every stored xdns mask to the names/addrs shape
+// xray-core 26.10.10 reads, wherever the panel keeps a finalmask.
+func migrateXdnsFinalmaskShape() error {
 	return db.Transaction(func(tx *gorm.DB) error {
 	return db.Transaction(func(tx *gorm.DB) error {
 		var inbounds []model.Inbound
 		var inbounds []model.Inbound
 		if err := tx.Select("id", "stream_settings").Find(&inbounds).Error; err != nil {
 		if err := tx.Select("id", "stream_settings").Find(&inbounds).Error; err != nil {
@@ -1991,7 +1991,7 @@ func migrateXdnsFinalmaskObjects() error {
 				}
 				}
 			}
 			}
 		}
 		}
-		return tx.Create(&model.HistoryOfSeeders{SeederName: "XdnsFinalmaskObjectsFix"}).Error
+		return tx.Create(&model.HistoryOfSeeders{SeederName: "XdnsFinalmaskNamesAddrsFix"}).Error
 	})
 	})
 }
 }
 
 

+ 12 - 10
internal/database/xdns_finalmask_migration_test.go

@@ -7,10 +7,12 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 )
 )
 
 
-const legacyXdnsFinalmask = `{"udp":[{"type":"xdns","settings":{"domains":["t.example.com"]}}]}`
+// legacyXdnsFinalmask is the 26.9.30 object shape the previous seeder left in panel DBs;
+// xray-core 26.10.10 reads neither its "name" nor its typed resolver.
+const legacyXdnsFinalmask = `{"udp":[{"type":"xdns","settings":{"domains":[{"name":"t.example.com","types":[16],"edns0":1232}],"resolvers":[{"type":"udp","settings":{"addr":"8.8.8.8:53"}}]}}]}`
 
 
-// assertXdnsUpgraded fails unless the finalmask's xdns domains are objects, the only
-// shape xray-core 26.9.30 parses.
+// assertXdnsUpgraded fails unless the finalmask's xdns mask uses the names/addrs lists,
+// the only shape xray-core 26.10.10 reads.
 func assertXdnsUpgraded(t *testing.T, where string, finalmask any) {
 func assertXdnsUpgraded(t *testing.T, where string, finalmask any) {
 	t.Helper()
 	t.Helper()
 	fm, _ := finalmask.(map[string]any)
 	fm, _ := finalmask.(map[string]any)
@@ -20,13 +22,13 @@ func assertXdnsUpgraded(t *testing.T, where string, finalmask any) {
 	}
 	}
 	mask, _ := udp[0].(map[string]any)
 	mask, _ := udp[0].(map[string]any)
 	settings, _ := mask["settings"].(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"])
+	got, err := json.Marshal(settings)
+	if err != nil {
+		t.Fatalf("%s: marshal xdns settings: %v", where, err)
 	}
 	}
-	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])
+	const want = `{"domains":[{"edns0":1232,"names":["t.example.com"],"types":[16]}],"resolvers":[{"addrs":["udp://8.8.8.8:53"]}]}`
+	if string(got) != want {
+		t.Fatalf("%s: xdns settings\n got: %s\nwant: %s", where, got, want)
 	}
 	}
 }
 }
 
 
@@ -49,7 +51,7 @@ func TestXdnsFinalmaskSeederUpgradesEveryStoredMask(t *testing.T) {
 	if err := GetDB().Create(sub).Error; err != nil {
 	if err := GetDB().Create(sub).Error; err != nil {
 		t.Fatalf("create outbound subscription: %v", err)
 		t.Fatalf("create outbound subscription: %v", err)
 	}
 	}
-	if err := GetDB().Where("seeder_name = ?", "XdnsFinalmaskObjectsFix").
+	if err := GetDB().Where("seeder_name = ?", "XdnsFinalmaskNamesAddrsFix").
 		Delete(&model.HistoryOfSeeders{}).Error; err != nil {
 		Delete(&model.HistoryOfSeeders{}).Error; err != nil {
 		t.Fatalf("clear seeder history: %v", err)
 		t.Fatalf("clear seeder history: %v", err)
 	}
 	}

+ 4 - 4
internal/util/link/outbound_test.go

@@ -87,10 +87,10 @@ 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.
+// A panel on xray-core 26.9.30 shares xdns objects whose "name" and typed resolvers
+// 26.10.10 ignores; imported verbatim, the outbound would dial with no domain.
 func TestParseLink_UpgradesLegacyXdnsFinalMask(t *testing.T) {
 func TestParseLink_UpgradesLegacyXdnsFinalMask(t *testing.T) {
-	fm := url.QueryEscape(`{"udp":[{"type":"xdns","settings":{"resolvers":["t.example.com+udp://8.8.8.8:53"]}}]}`)
+	fm := url.QueryEscape(`{"udp":[{"type":"xdns","settings":{"domains":[{"name":"t.example.com","types":[16],"edns0":1232}],"resolvers":[{"type":"udp","settings":{"addr":"8.8.8.8:53"}}]}}]}`)
 	res, err := ParseLink("vless://[email protected]:53?type=kcp&security=none&fm=" + fm + "#dns")
 	res, err := ParseLink("vless://[email protected]:53?type=kcp&security=none&fm=" + fm + "#dns")
 	if err != nil {
 	if err != nil {
 		t.Fatalf("parse vless with fm: %v", err)
 		t.Fatalf("parse vless with fm: %v", err)
@@ -100,7 +100,7 @@ func TestParseLink_UpgradesLegacyXdnsFinalMask(t *testing.T) {
 	if err != nil {
 	if err != nil {
 		t.Fatalf("marshal finalmask: %v", err)
 		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"}]}`
+	want := `{"udp":[{"settings":{"domains":[{"edns0":1232,"names":["t.example.com"],"types":[16]}],"resolvers":[{"addrs":["udp://8.8.8.8:53"]}]},"type":"xdns"}]}`
 	if string(got) != want {
 	if string(got) != want {
 		t.Fatalf("imported finalmask\n got: %s\nwant: %s", got, want)
 		t.Fatalf("imported finalmask\n got: %s\nwant: %s", got, want)
 	}
 	}

+ 89 - 31
internal/util/maskcompat/xdns.go

@@ -1,6 +1,9 @@
 package maskcompat
 package maskcompat
 
 
-import "strings"
+import (
+	"slices"
+	"strings"
+)
 
 
 // legacyXdnsEDNS0 is the EDNS0 payload the pre-26.9.30 xdns always negotiated;
 // 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.
 // the object shape makes it opt-in and caps every answer at 512 bytes without it.
@@ -8,8 +11,8 @@ const legacyXdnsEDNS0 = 1232
 
 
 var legacyXdnsRecordTypes = map[string]int{"": 16, "txt": 16, "a": 1, "aaaa": 28}
 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.
+// UpgradeLegacyXdns rewrites xdns masks into the names/addrs lists xray-core 26.10.10
+// (#7090) parses: it ignores 26.9.30's name/type+settings keys and fails the string lists.
 func UpgradeLegacyXdns(finalmask any) bool {
 func UpgradeLegacyXdns(finalmask any) bool {
 	fm, _ := finalmask.(map[string]any)
 	fm, _ := finalmask.(map[string]any)
 	masks, _ := fm["udp"].([]any)
 	masks, _ := fm["udp"].([]any)
@@ -31,48 +34,56 @@ func UpgradeLegacyXdns(finalmask any) bool {
 func upgradeLegacyXdnsSettings(settings map[string]any) bool {
 func upgradeLegacyXdnsSettings(settings map[string]any) bool {
 	rawDomains, _ := settings["domains"].([]any)
 	rawDomains, _ := settings["domains"].([]any)
 	rawResolvers, _ := settings["resolvers"].([]any)
 	rawResolvers, _ := settings["resolvers"].([]any)
-	if !hasLegacyXdnsEntry(rawDomains) && !hasLegacyXdnsEntry(rawResolvers) {
+	if !slices.ContainsFunc(rawDomains, isLegacyXdnsDomain) && !slices.ContainsFunc(rawResolvers, isLegacyXdnsResolver) {
 		return false
 		return false
 	}
 	}
 	domains := make([]any, 0, len(rawDomains))
 	domains := make([]any, 0, len(rawDomains))
 	listed := map[string]bool{}
 	listed := map[string]bool{}
 	addDomain := func(domain map[string]any) {
 	addDomain := func(domain map[string]any) {
-		key, _ := domain["name"].(string)
-		key = strings.ToLower(key)
-		if key != "" && listed[key] {
-			return
+		names, _ := domain["names"].([]any)
+		for _, name := range names {
+			if s, ok := name.(string); ok {
+				listed[strings.ToLower(s)] = true
+			}
 		}
 		}
-		listed[key] = true
 		domains = append(domains, domain)
 		domains = append(domains, domain)
 	}
 	}
+	addLegacyDomain := func(domain map[string]any) {
+		if name, _ := domain["names"].([]any)[0].(string); !listed[strings.ToLower(name)] {
+			addDomain(domain)
+		}
+	}
 	for _, entry := range rawDomains {
 	for _, entry := range rawDomains {
 		switch value := entry.(type) {
 		switch value := entry.(type) {
 		case map[string]any:
 		case map[string]any:
-			addDomain(value)
+			addDomain(upgradeXdnsDomainObject(value))
 		case string:
 		case string:
 			if domain, ok := legacyXdnsDomain(value); ok {
 			if domain, ok := legacyXdnsDomain(value); ok {
-				addDomain(domain)
+				addLegacyDomain(domain)
 			}
 			}
 		}
 		}
 	}
 	}
 	resolvers := make([]any, 0, len(rawResolvers))
 	resolvers := make([]any, 0, len(rawResolvers))
 	for _, entry := range rawResolvers {
 	for _, entry := range rawResolvers {
-		spec, ok := entry.(string)
-		if !ok {
+		switch value := entry.(type) {
+		case string:
+			head, addr, found := strings.Cut(value, "+udp://")
+			addr = strings.TrimSpace(addr)
+			domain, valid := legacyXdnsDomain(head)
+			if !found || addr == "" || !valid {
+				continue
+			}
+			addLegacyDomain(domain)
+			resolvers = append(resolvers, map[string]any{"addrs": []any{"udp://" + addr}})
+		case map[string]any:
+			if !isLegacyXdnsResolver(value) {
+				resolvers = append(resolvers, value)
+			} else if addr, ok := legacyXdnsResolverAddr(value); ok {
+				resolvers = append(resolvers, map[string]any{"addrs": []any{addr}})
+			}
+		default:
 			resolvers = append(resolvers, entry)
 			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
 	settings["domains"] = domains
 	if len(resolvers) > 0 {
 	if len(resolvers) > 0 {
@@ -83,6 +94,40 @@ func upgradeLegacyXdnsSettings(settings map[string]any) bool {
 	return true
 	return true
 }
 }
 
 
+// upgradeXdnsDomainObject folds a 26.9.30 domain's single "name" into the "names" list.
+func upgradeXdnsDomainObject(domain map[string]any) map[string]any {
+	raw, legacy := domain["name"]
+	if !legacy {
+		return domain
+	}
+	delete(domain, "name")
+	name, _ := raw.(string)
+	name = strings.TrimSpace(name)
+	names, _ := domain["names"].([]any)
+	if name != "" && !slices.Contains(names, any(name)) {
+		names = append([]any{name}, names...)
+	}
+	if names == nil {
+		names = []any{}
+	}
+	domain["names"] = names
+	return domain
+}
+
+// legacyXdnsResolverAddr turns a 26.9.30 {type, settings:{addr}} resolver into the URL
+// the addrs list takes; that loader refused every type but udp and tcp.
+func legacyXdnsResolverAddr(resolver map[string]any) (string, bool) {
+	kind, _ := resolver["type"].(string)
+	kind = strings.ToLower(strings.TrimSpace(kind))
+	settings, _ := resolver["settings"].(map[string]any)
+	addr, _ := settings["addr"].(string)
+	addr = strings.TrimSpace(addr)
+	if (kind != "udp" && kind != "tcp") || addr == "" {
+		return "", false
+	}
+	return kind + "://" + addr, true
+}
+
 // legacyXdnsDomain parses the "name[:txt|a|aaaa]" spec both legacy lists used.
 // legacyXdnsDomain parses the "name[:txt|a|aaaa]" spec both legacy lists used.
 func legacyXdnsDomain(spec string) (map[string]any, bool) {
 func legacyXdnsDomain(spec string) (map[string]any, bool) {
 	name, method := strings.TrimSpace(spec), ""
 	name, method := strings.TrimSpace(spec), ""
@@ -94,14 +139,27 @@ func legacyXdnsDomain(spec string) (map[string]any, bool) {
 	if name == "" || !known {
 	if name == "" || !known {
 		return nil, false
 		return nil, false
 	}
 	}
-	return map[string]any{"name": name, "types": []any{recordType}, "edns0": legacyXdnsEDNS0}, true
+	return map[string]any{"names": []any{name}, "types": []any{recordType}, "edns0": legacyXdnsEDNS0}, true
 }
 }
 
 
-func hasLegacyXdnsEntry(values []any) bool {
-	for _, value := range values {
-		if _, ok := value.(string); ok {
-			return true
-		}
+func isLegacyXdnsDomain(value any) bool {
+	switch domain := value.(type) {
+	case string:
+		return true
+	case map[string]any:
+		_, legacy := domain["name"]
+		return legacy
+	}
+	return false
+}
+
+func isLegacyXdnsResolver(value any) bool {
+	switch resolver := value.(type) {
+	case string:
+		return true
+	case map[string]any:
+		_, current := resolver["addrs"]
+		return !current
 	}
 	}
 	return false
 	return false
 }
 }

+ 69 - 29
internal/util/maskcompat/xdns_test.go

@@ -2,14 +2,16 @@ package maskcompat
 
 
 import (
 import (
 	"encoding/json"
 	"encoding/json"
+	"slices"
 	"testing"
 	"testing"
 
 
 	"github.com/xtls/xray-core/infra/conf"
 	"github.com/xtls/xray-core/infra/conf"
+	"github.com/xtls/xray-core/transport/internet/finalmask/xdns"
 )
 )
 
 
-// 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 {
+// coreXdnsView runs an xdns mask's settings through conf.XDNS, the loader the core
+// calls at startup, and returns the domain names and resolvers the core would use.
+func coreXdnsView(t *testing.T, settings any) (domains, resolvers []string, err error) {
 	t.Helper()
 	t.Helper()
 	raw, err := json.Marshal(settings)
 	raw, err := json.Marshal(settings)
 	if err != nil {
 	if err != nil {
@@ -17,42 +19,75 @@ func buildXdnsSettings(t *testing.T, settings any) error {
 	}
 	}
 	var mask conf.XDNS
 	var mask conf.XDNS
 	if err := json.Unmarshal(raw, &mask); err != nil {
 	if err := json.Unmarshal(raw, &mask); err != nil {
-		return err
+		return nil, nil, err
 	}
 	}
-	_, err = mask.Build()
-	return err
+	built, err := mask.Build()
+	if err != nil {
+		return nil, nil, err
+	}
+	config := built.(*xdns.Config)
+	for _, domain := range config.Domains {
+		domains = append(domains, domain.Name)
+	}
+	for _, resolver := range config.Resolvers {
+		resolvers = append(resolvers, resolver.Type+"://"+resolver.Addr)
+	}
+	return domains, resolvers, nil
 }
 }
 
 
 func TestUpgradeLegacyXdns(t *testing.T) {
 func TestUpgradeLegacyXdns(t *testing.T) {
 	tests := []struct {
 	tests := []struct {
-		name string
-		mask string
-		want string
+		name          string
+		mask          string
+		want          string
+		wantDomains   []string
+		wantResolvers []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:        "bare server domain becomes TXT with the legacy EDNS0 size",
+			mask:        `{"type":"xdns","settings":{"domains":["t.example.com"]}}`,
+			want:        `{"domains":[{"edns0":1232,"names":["t.example.com"],"types":[16]}]}`,
+			wantDomains: []string{"t.example.com"},
+		},
+		{
+			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,"names":["a.example.com"],"types":[1]},{"edns0":1232,"names":["q.example.com"],"types":[28]},{"edns0":1232,"names":["t.example.com"],"types":[16]}]}`,
+			wantDomains: []string{"a.example.com", "q.example.com", "t.example.com"},
+		},
+		{
+			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,"names":["t.example.com"],"types":[1]}],"resolvers":[{"addrs":["udp://8.8.8.8:53"]}]}`,
+			wantDomains:   []string{"t.example.com"},
+			wantResolvers: []string{"udp://8.8.8.8:53"},
 		},
 		},
 		{
 		{
-			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:          "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,"names":["t.example.com"],"types":[16]}],"resolvers":[{"addrs":["udp://1.1.1.1:53"]}]}`,
+			wantDomains:   []string{"t.example.com"},
+			wantResolvers: []string{"udp://1.1.1.1:53"},
 		},
 		},
 		{
 		{
-			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:        "string 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,"names":["t.example.com"],"types":[16]}]}`,
+			wantDomains: []string{"t.example.com"},
 		},
 		},
 		{
 		{
-			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:          "26.9.30 domain and resolver objects move to names and addrs",
+			mask:          `{"type":"xdns","settings":{"domains":[{"name":"t.example.com","types":[16,28],"edns0":1232,"lenLimit":200}],"resolvers":[{"type":"tcp","settings":{"addr":"8.8.8.8:53"}},{"type":"udp","settings":{"addr":"1.1.1.1:5353"}}],"extraPoll":1}}`,
+			want:          `{"domains":[{"edns0":1232,"lenLimit":200,"names":["t.example.com"],"types":[16,28]}],"extraPoll":1,"resolvers":[{"addrs":["tcp://8.8.8.8:53"]},{"addrs":["udp://1.1.1.1:5353"]}]}`,
+			wantDomains:   []string{"t.example.com"},
+			wantResolvers: []string{"tcp://8.8.8.8:53", "udp://1.1.1.1:5353"},
 		},
 		},
 		{
 		{
-			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]}]}`,
+			name:          "26.9.30 resolvers the old loader refused are dropped",
+			mask:          `{"type":"xdns","settings":{"domains":[{"name":"t.example.com"}],"resolvers":[{"type":"doh","settings":{"addr":"dns.example.com"}},{"type":"udp","settings":{}},{"type":"udp","settings":{"addr":"9.9.9.9:53"}}]}}`,
+			want:          `{"domains":[{"names":["t.example.com"]}],"resolvers":[{"addrs":["udp://9.9.9.9:53"]}]}`,
+			wantDomains:   []string{"t.example.com"},
+			wantResolvers: []string{"udp://9.9.9.9:53"},
 		},
 		},
 	}
 	}
 	for _, tc := range tests {
 	for _, tc := range tests {
@@ -61,8 +96,9 @@ func TestUpgradeLegacyXdns(t *testing.T) {
 			if err := json.Unmarshal([]byte(tc.mask), &mask); err != nil {
 			if err := json.Unmarshal([]byte(tc.mask), &mask); err != nil {
 				t.Fatalf("unmarshal mask: %v", err)
 				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")
+			domains, resolvers, err := coreXdnsView(t, mask["settings"])
+			if err == nil && slices.Equal(domains, tc.wantDomains) && slices.Equal(resolvers, tc.wantResolvers) {
+				t.Fatal("the core already serves the legacy shape as intended; the upgrade is no longer needed")
 			}
 			}
 			finalmask := map[string]any{"udp": []any{mask}}
 			finalmask := map[string]any{"udp": []any{mask}}
 			if !UpgradeLegacyXdns(finalmask) {
 			if !UpgradeLegacyXdns(finalmask) {
@@ -75,21 +111,25 @@ func TestUpgradeLegacyXdns(t *testing.T) {
 			if string(got) != tc.want {
 			if string(got) != tc.want {
 				t.Fatalf("upgraded settings\n got: %s\nwant: %s", got, tc.want)
 				t.Fatalf("upgraded settings\n got: %s\nwant: %s", got, tc.want)
 			}
 			}
-			if err := buildXdnsSettings(t, mask["settings"]); err != nil {
+			domains, resolvers, err = coreXdnsView(t, mask["settings"])
+			if err != nil {
 				t.Fatalf("the core refuses the upgraded settings: %v", err)
 				t.Fatalf("the core refuses the upgraded settings: %v", err)
 			}
 			}
+			if !slices.Equal(domains, tc.wantDomains) || !slices.Equal(resolvers, tc.wantResolvers) {
+				t.Fatalf("the core serves domains %v resolvers %v, want %v %v", domains, resolvers, tc.wantDomains, tc.wantResolvers)
+			}
 		})
 		})
 	}
 	}
 }
 }
 
 
 func TestUpgradeLegacyXdnsLeavesCurrentShapeAlone(t *testing.T) {
 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"}}]}`
+	const current = `{"udp":[{"type":"xdns","settings":{"domains":[{"names":["t.example.com","u.example.com"],"types":[16,28],"edns0":1232}],"resolvers":[{"addrs":["tcp://8.8.8.8:53","1.1.1.1"]}],"extraPoll":2}},{"type":"salamander","settings":{"password":"x"}}]}`
 	var finalmask map[string]any
 	var finalmask map[string]any
 	if err := json.Unmarshal([]byte(current), &finalmask); err != nil {
 	if err := json.Unmarshal([]byte(current), &finalmask); err != nil {
 		t.Fatalf("unmarshal finalmask: %v", err)
 		t.Fatalf("unmarshal finalmask: %v", err)
 	}
 	}
 	if UpgradeLegacyXdns(finalmask) {
 	if UpgradeLegacyXdns(finalmask) {
-		t.Fatal("UpgradeLegacyXdns rewrote a mask that is already in the object shape")
+		t.Fatal("UpgradeLegacyXdns rewrote a mask that is already in the names/addrs shape")
 	}
 	}
 	var want map[string]any
 	var want map[string]any
 	_ = json.Unmarshal([]byte(current), &want)
 	_ = json.Unmarshal([]byte(current), &want)

+ 2 - 2
internal/web/service/inbound.go

@@ -666,8 +666,8 @@ func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) {
 	inbound.StreamSettings = canonicalizeLegacyXdnsMasks(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.
+// canonicalizeLegacyXdnsMasks stores an xdns mask posted in a pre-26.10.10 shape in
+// the names/addrs lists the core reads, as GetXrayConfig would heal it anyway.
 func canonicalizeLegacyXdnsMasks(streamSettings string) string {
 func canonicalizeLegacyXdnsMasks(streamSettings string) string {
 	if streamSettings == "" {
 	if streamSettings == "" {
 		return streamSettings
 		return streamSettings

+ 48 - 24
internal/web/service/inbound_finalmask_xdns_test.go

@@ -8,9 +8,13 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"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"]}}]}}`
+// legacyXdnsStream carries the 26.9.30 xdns objects, whose "name" and typed resolvers
+// xray-core 26.10.10 ignores: the mask would load with no domain at all.
+const legacyXdnsStream = `{"network":"kcp","security":"none","kcpSettings":{"mtu":900},"finalmask":{"udp":[{"type":"xdns","settings":{"domains":[{"name":"t.example.com","types":[16],"edns0":1232}],"resolvers":[{"type":"udp","settings":{"addr":"8.8.8.8:53"}}]}}]}}`
 
 
-func firstXdnsDomain(t *testing.T, stream map[string]any) any {
+const upgradedXdnsSettings = `{"domains":[{"edns0":1232,"names":["t.example.com"],"types":[16]}],"resolvers":[{"addrs":["udp://8.8.8.8:53"]}]}`
+
+func assertXdnsSettingsUpgraded(t *testing.T, stream map[string]any) {
 	t.Helper()
 	t.Helper()
 	finalmask, _ := stream["finalmask"].(map[string]any)
 	finalmask, _ := stream["finalmask"].(map[string]any)
 	udp, _ := finalmask["udp"].([]any)
 	udp, _ := finalmask["udp"].([]any)
@@ -18,17 +22,18 @@ func firstXdnsDomain(t *testing.T, stream map[string]any) any {
 		t.Fatalf("finalmask.udp = %v, want one mask", finalmask["udp"])
 		t.Fatalf("finalmask.udp = %v, want one mask", finalmask["udp"])
 	}
 	}
 	mask, _ := udp[0].(map[string]any)
 	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"])
+	got, err := json.Marshal(mask["settings"])
+	if err != nil {
+		t.Fatalf("marshal xdns settings: %v", err)
+	}
+	if string(got) != upgradedXdnsSettings {
+		t.Fatalf("xdns settings\n got: %s\nwant: %s", got, upgradedXdnsSettings)
 	}
 	}
-	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) {
+// An API client or an older panel's export can still post the 26.9.30 shape; stored as
+// sent it would reach the sub links and the form in a shape the core silently empties.
+func TestAddInbound_StoresXdnsMaskInNamesShape(t *testing.T) {
 	setupConflictDB(t)
 	setupConflictDB(t)
 	in := &model.Inbound{
 	in := &model.Inbound{
 		Tag: "in-45300-kcp", Enable: true, Listen: "0.0.0.0", Port: 45300, Protocol: model.VLESS,
 		Tag: "in-45300-kcp", Enable: true, Listen: "0.0.0.0", Port: 45300, Protocol: model.VLESS,
@@ -46,28 +51,16 @@ func TestAddInbound_StoresXdnsMaskInObjectShape(t *testing.T) {
 	if err := json.Unmarshal([]byte(stored.StreamSettings), &stream); err != nil {
 	if err := json.Unmarshal([]byte(stored.StreamSettings), &stream); err != nil {
 		t.Fatalf("stored stream is not JSON: %v", err)
 		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))
-	}
+	assertXdnsSettingsUpgraded(t, stream)
 }
 }
 
 
 // A row that never went through the save path (restored backup, node sync, direct DB
 // 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.
+// edit) must still reach the core with its xdns domains, or the mask serves nothing.
 func TestGetXrayConfig_UpgradesLegacyXdnsMask(t *testing.T) {
 func TestGetXrayConfig_UpgradesLegacyXdnsMask(t *testing.T) {
 	setupConflictDB(t)
 	setupConflictDB(t)
 	seedInboundConflict(t, "in-45301-kcp", "127.0.0.1", 45301, model.VLESS,
 	seedInboundConflict(t, "in-45301-kcp", "127.0.0.1", 45301, model.VLESS,
 		legacyXdnsStream, `{"clients":[],"decryption":"none"}`)
 		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()
 	cfg, err := (&XrayService{}).GetXrayConfig()
 	if err != nil {
 	if err != nil {
 		t.Fatalf("GetXrayConfig: %v", err)
 		t.Fatalf("GetXrayConfig: %v", err)
@@ -84,8 +77,39 @@ func TestGetXrayConfig_UpgradesLegacyXdnsMask(t *testing.T) {
 		if err := json.Unmarshal(raw, &emitted); err != nil {
 		if err := json.Unmarshal(raw, &emitted); err != nil {
 			t.Fatalf("decode emitted inbound: %v", err)
 			t.Fatalf("decode emitted inbound: %v", err)
 		}
 		}
+		stream, _ := emitted["streamSettings"].(map[string]any)
+		assertXdnsSettingsUpgraded(t, stream)
 		assertXrayAccepts(t, "the healed xdns inbound", buildGoldenInbound(t, emitted))
 		assertXrayAccepts(t, "the healed xdns inbound", buildGoldenInbound(t, emitted))
 		return
 		return
 	}
 	}
 	t.Fatal("inbound in-45301-kcp not found in the generated config")
 	t.Fatal("inbound in-45301-kcp not found in the generated config")
 }
 }
+
+// A template pasted from a 26.9.30 panel after the seeder ran passes the save check,
+// since the core loads that shape without error; only the build-time heal catches it.
+func TestGetXrayConfig_UpgradesTemplateOutboundXdnsMask(t *testing.T) {
+	setupConflictDB(t)
+	template := `{"outbounds":[{"protocol":"freedom","tag":"direct"},{"protocol":"vless","tag":"dns-tunnel",
+		"settings":{"vnext":[{"address":"t.example.com","port":53,"users":[{"id":"c9f0c2d0-0000-4000-8000-000000000000","encryption":"none"}]}]},
+		"streamSettings":` + legacyXdnsStream + `}]}`
+	if err := (&SettingService{}).saveSetting("xrayTemplateConfig", template); err != nil {
+		t.Fatalf("seed template: %v", err)
+	}
+
+	cfg, err := (&XrayService{}).GetXrayConfig()
+	if err != nil {
+		t.Fatalf("GetXrayConfig: %v", err)
+	}
+	var outbounds []map[string]any
+	if err := json.Unmarshal(cfg.OutboundConfigs, &outbounds); err != nil {
+		t.Fatalf("decode emitted outbounds: %v", err)
+	}
+	for _, outbound := range outbounds {
+		if outbound["tag"] == "dns-tunnel" {
+			stream, _ := outbound["streamSettings"].(map[string]any)
+			assertXdnsSettingsUpgraded(t, stream)
+			return
+		}
+	}
+	t.Fatal("outbound dns-tunnel not found in the generated config")
+}

+ 16 - 12
internal/web/service/xray.go

@@ -180,10 +180,10 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
 	xrayConfig.API = ensureAPIServices(xrayConfig.API)
 	xrayConfig.API = ensureAPIServices(xrayConfig.API)
 	xrayConfig.Policy = ensureStatsPolicy(xrayConfig.Policy)
 	xrayConfig.Policy = ensureStatsPolicy(xrayConfig.Policy)
 	xrayConfig.RouterConfig = stripDisabledRules(xrayConfig.RouterConfig)
 	xrayConfig.RouterConfig = stripDisabledRules(xrayConfig.RouterConfig)
-	// Template outbounds authored before the xray-core #6258 XHTTP rename may
-	// still carry sessionPlacement/sessionKey; lift them too (same reason as
-	// the per-inbound lift below).
-	xrayConfig.OutboundConfigs = liftOutboundsXhttpSessionIDKeys(xrayConfig.OutboundConfigs)
+	// A pasted or restored template can still carry pre-#6258 XHTTP session keys or a
+	// pre-26.10.10 xdns mask the core silently empties; heal them like the inbounds below.
+	xrayConfig.OutboundConfigs = healOutboundStreams(xrayConfig.OutboundConfigs,
+		liftXhttpSessionIDKeys, upgradeStreamLegacyXdns)
 	// Bridge amneziawg outbounds before anything else reads OutboundConfigs;
 	// Bridge amneziawg outbounds before anything else reads OutboundConfigs;
 	// the core has no amneziawg proxy and would reject the raw entry.
 	// the core has no amneziawg proxy and would reject the raw entry.
 	if err := transformAmneziaWGOutbounds(xrayConfig); err != nil {
 	if err := transformAmneziaWGOutbounds(xrayConfig); err != nil {
@@ -376,7 +376,7 @@ 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)
 				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.
+			// A row that skipped the save path can still carry an xdns shape older than 26.10.10.
 			maskcompat.UpgradeLegacyXdns(stream["finalmask"])
 			maskcompat.UpgradeLegacyXdns(stream["finalmask"])
 
 
 			// xray-core v26.6.22 (#6258) renamed the XHTTP session keys and
 			// xray-core v26.6.22 (#6258) renamed the XHTTP session keys and
@@ -1712,11 +1712,9 @@ func liftXhttpSessionIDKeys(stream map[string]any) bool {
 	return changed
 	return changed
 }
 }
 
 
-// liftOutboundsXhttpSessionIDKeys applies liftXhttpSessionIDKeys to every
-// outbound's streamSettings in the raw outbounds array. The original bytes are
-// returned untouched when nothing needs lifting, so an unchanged config never
-// looks modified to the hot-reload diff.
-func liftOutboundsXhttpSessionIDKeys(raw json_util.RawMessage) json_util.RawMessage {
+// healOutboundStreams applies every heal to each outbound's streamSettings, returning the
+// original bytes when none changed anything so the hot-reload diff sees no edit.
+func healOutboundStreams(raw json_util.RawMessage, heals ...func(stream map[string]any) bool) json_util.RawMessage {
 	if len(raw) == 0 {
 	if len(raw) == 0 {
 		return raw
 		return raw
 	}
 	}
@@ -1727,8 +1725,10 @@ func liftOutboundsXhttpSessionIDKeys(raw json_util.RawMessage) json_util.RawMess
 	changed := false
 	changed := false
 	for _, ob := range outbounds {
 	for _, ob := range outbounds {
 		if stream, ok := ob["streamSettings"].(map[string]any); ok {
 		if stream, ok := ob["streamSettings"].(map[string]any); ok {
-			if liftXhttpSessionIDKeys(stream) {
-				changed = true
+			for _, heal := range heals {
+				if heal(stream) {
+					changed = true
+				}
 			}
 			}
 		}
 		}
 	}
 	}
@@ -1740,3 +1740,7 @@ func liftOutboundsXhttpSessionIDKeys(raw json_util.RawMessage) json_util.RawMess
 	}
 	}
 	return raw
 	return raw
 }
 }
+
+func upgradeStreamLegacyXdns(stream map[string]any) bool {
+	return maskcompat.UpgradeLegacyXdns(stream["finalmask"])
+}

+ 3 - 3
internal/web/service/xray_xhttp_session_test.go

@@ -57,9 +57,9 @@ func TestLiftXhttpSessionIDKeys(t *testing.T) {
 	})
 	})
 }
 }
 
 
-func TestLiftOutboundsXhttpSessionIDKeys(t *testing.T) {
+func TestHealOutboundStreams_LiftsXhttpSessionIDKeys(t *testing.T) {
 	raw := json_util.RawMessage(`[{"protocol":"vless","streamSettings":{"network":"xhttp","xhttpSettings":{"sessionKey":"x_session","sessionPlacement":"query"}}}]`)
 	raw := json_util.RawMessage(`[{"protocol":"vless","streamSettings":{"network":"xhttp","xhttpSettings":{"sessionKey":"x_session","sessionPlacement":"query"}}}]`)
-	out := liftOutboundsXhttpSessionIDKeys(raw)
+	out := healOutboundStreams(raw, liftXhttpSessionIDKeys)
 
 
 	var parsed []map[string]any
 	var parsed []map[string]any
 	if err := json.Unmarshal(out, &parsed); err != nil {
 	if err := json.Unmarshal(out, &parsed); err != nil {
@@ -75,7 +75,7 @@ func TestLiftOutboundsXhttpSessionIDKeys(t *testing.T) {
 
 
 	// Unchanged input must return byte-identical output (no spurious hot-reload).
 	// Unchanged input must return byte-identical output (no spurious hot-reload).
 	clean := json_util.RawMessage(`[{"protocol":"freedom"}]`)
 	clean := json_util.RawMessage(`[{"protocol":"freedom"}]`)
-	if got := liftOutboundsXhttpSessionIDKeys(clean); string(got) != string(clean) {
+	if got := healOutboundStreams(clean, liftXhttpSessionIDKeys); string(got) != string(clean) {
 		t.Fatalf("clean outbounds were rewritten: %s", got)
 		t.Fatalf("clean outbounds were rewritten: %s", got)
 	}
 	}
 }
 }

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

@@ -1929,6 +1929,7 @@
         "serverNamePlaceholder": "اسم الخادم",
         "serverNamePlaceholder": "اسم الخادم",
         "verifyPeerName": "التحقق من اسم peer",
         "verifyPeerName": "التحقق من اسم peer",
         "pinnedSha256": "SHA256 مثبت",
         "pinnedSha256": "SHA256 مثبت",
+        "useSystemCA": "استخدام CA النظام",
         "shortId": "Short ID",
         "shortId": "Short ID",
         "sockopts": "Sockopts",
         "sockopts": "Sockopts",
         "keepAliveInterval": "فاصل keep alive",
         "keepAliveInterval": "فاصل keep alive",

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

@@ -2047,6 +2047,7 @@
         "serverNamePlaceholder": "server name",
         "serverNamePlaceholder": "server name",
         "verifyPeerName": "Verify peer name",
         "verifyPeerName": "Verify peer name",
         "pinnedSha256": "Pinned SHA256",
         "pinnedSha256": "Pinned SHA256",
+        "useSystemCA": "Use system CA",
         "shortId": "Short ID",
         "shortId": "Short ID",
         "sockopts": "Sockopts",
         "sockopts": "Sockopts",
         "keepAliveInterval": "Keep alive interval",
         "keepAliveInterval": "Keep alive interval",

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

@@ -1929,6 +1929,7 @@
         "serverNamePlaceholder": "nombre del servidor",
         "serverNamePlaceholder": "nombre del servidor",
         "verifyPeerName": "Verificar nombre del peer",
         "verifyPeerName": "Verificar nombre del peer",
         "pinnedSha256": "SHA256 pinned",
         "pinnedSha256": "SHA256 pinned",
+        "useSystemCA": "Usar CA del sistema",
         "shortId": "Short ID",
         "shortId": "Short ID",
         "sockopts": "Sockopts",
         "sockopts": "Sockopts",
         "keepAliveInterval": "Intervalo keep alive",
         "keepAliveInterval": "Intervalo keep alive",

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

@@ -1929,6 +1929,7 @@
         "serverNamePlaceholder": "نام سرور",
         "serverNamePlaceholder": "نام سرور",
         "verifyPeerName": "تایید نام Peer",
         "verifyPeerName": "تایید نام Peer",
         "pinnedSha256": "SHA256 پین‌شده",
         "pinnedSha256": "SHA256 پین‌شده",
+        "useSystemCA": "استفاده از CA سیستم",
         "shortId": "Short ID",
         "shortId": "Short ID",
         "sockopts": "Sockopts",
         "sockopts": "Sockopts",
         "keepAliveInterval": "بازه Keep alive",
         "keepAliveInterval": "بازه Keep alive",

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

@@ -1929,6 +1929,7 @@
         "serverNamePlaceholder": "nama server",
         "serverNamePlaceholder": "nama server",
         "verifyPeerName": "Verifikasi nama peer",
         "verifyPeerName": "Verifikasi nama peer",
         "pinnedSha256": "SHA256 pinned",
         "pinnedSha256": "SHA256 pinned",
+        "useSystemCA": "Gunakan CA sistem",
         "shortId": "Short ID",
         "shortId": "Short ID",
         "sockopts": "Sockopts",
         "sockopts": "Sockopts",
         "keepAliveInterval": "Interval keep alive",
         "keepAliveInterval": "Interval keep alive",

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

@@ -1929,6 +1929,7 @@
         "serverNamePlaceholder": "サーバー名",
         "serverNamePlaceholder": "サーバー名",
         "verifyPeerName": "peer 名を検証",
         "verifyPeerName": "peer 名を検証",
         "pinnedSha256": "Pinned SHA256",
         "pinnedSha256": "Pinned SHA256",
+        "useSystemCA": "システムの CA を使用",
         "shortId": "Short ID",
         "shortId": "Short ID",
         "sockopts": "Sockopts",
         "sockopts": "Sockopts",
         "keepAliveInterval": "keep alive 間隔",
         "keepAliveInterval": "keep alive 間隔",

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

@@ -1929,6 +1929,7 @@
         "serverNamePlaceholder": "nome do servidor",
         "serverNamePlaceholder": "nome do servidor",
         "verifyPeerName": "Verificar nome do peer",
         "verifyPeerName": "Verificar nome do peer",
         "pinnedSha256": "SHA256 pinned",
         "pinnedSha256": "SHA256 pinned",
+        "useSystemCA": "Usar CA do sistema",
         "shortId": "Short ID",
         "shortId": "Short ID",
         "sockopts": "Sockopts",
         "sockopts": "Sockopts",
         "keepAliveInterval": "Intervalo keep alive",
         "keepAliveInterval": "Intervalo keep alive",

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

@@ -1929,6 +1929,7 @@
         "serverNamePlaceholder": "имя сервера",
         "serverNamePlaceholder": "имя сервера",
         "verifyPeerName": "Проверять имя peer",
         "verifyPeerName": "Проверять имя peer",
         "pinnedSha256": "Pinned SHA256",
         "pinnedSha256": "Pinned SHA256",
+        "useSystemCA": "Использовать системные CA",
         "shortId": "Short ID",
         "shortId": "Short ID",
         "sockopts": "Sockopts",
         "sockopts": "Sockopts",
         "keepAliveInterval": "Интервал keep alive",
         "keepAliveInterval": "Интервал keep alive",

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

@@ -1929,6 +1929,7 @@
         "serverNamePlaceholder": "sunucu adı",
         "serverNamePlaceholder": "sunucu adı",
         "verifyPeerName": "Peer Adını Doğrula",
         "verifyPeerName": "Peer Adını Doğrula",
         "pinnedSha256": "Sabitlenmiş SHA256",
         "pinnedSha256": "Sabitlenmiş SHA256",
+        "useSystemCA": "Sistem CA'sını kullan",
         "shortId": "Kısa Kimlik",
         "shortId": "Kısa Kimlik",
         "sockopts": "Sockopts",
         "sockopts": "Sockopts",
         "keepAliveInterval": "Keep Alive Aralığı",
         "keepAliveInterval": "Keep Alive Aralığı",

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

@@ -1929,6 +1929,7 @@
         "serverNamePlaceholder": "ім'я сервера",
         "serverNamePlaceholder": "ім'я сервера",
         "verifyPeerName": "Перевіряти ім'я peer",
         "verifyPeerName": "Перевіряти ім'я peer",
         "pinnedSha256": "Pinned SHA256",
         "pinnedSha256": "Pinned SHA256",
+        "useSystemCA": "Використовувати системні CA",
         "shortId": "Short ID",
         "shortId": "Short ID",
         "sockopts": "Sockopts",
         "sockopts": "Sockopts",
         "keepAliveInterval": "Інтервал keep alive",
         "keepAliveInterval": "Інтервал keep alive",

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

@@ -1929,6 +1929,7 @@
         "serverNamePlaceholder": "tên máy chủ",
         "serverNamePlaceholder": "tên máy chủ",
         "verifyPeerName": "Xác minh tên peer",
         "verifyPeerName": "Xác minh tên peer",
         "pinnedSha256": "SHA256 pinned",
         "pinnedSha256": "SHA256 pinned",
+        "useSystemCA": "Dùng CA của hệ thống",
         "shortId": "Short ID",
         "shortId": "Short ID",
         "sockopts": "Sockopts",
         "sockopts": "Sockopts",
         "keepAliveInterval": "Khoảng keep alive",
         "keepAliveInterval": "Khoảng keep alive",

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

@@ -1929,6 +1929,7 @@
         "serverNamePlaceholder": "服务器名",
         "serverNamePlaceholder": "服务器名",
         "verifyPeerName": "验证 peer 名称",
         "verifyPeerName": "验证 peer 名称",
         "pinnedSha256": "Pinned SHA256",
         "pinnedSha256": "Pinned SHA256",
+        "useSystemCA": "使用系统 CA",
         "shortId": "Short ID",
         "shortId": "Short ID",
         "sockopts": "Sockopts",
         "sockopts": "Sockopts",
         "keepAliveInterval": "keep alive 间隔",
         "keepAliveInterval": "keep alive 间隔",

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

@@ -1929,6 +1929,7 @@
         "serverNamePlaceholder": "伺服器名稱",
         "serverNamePlaceholder": "伺服器名稱",
         "verifyPeerName": "驗證 peer 名稱",
         "verifyPeerName": "驗證 peer 名稱",
         "pinnedSha256": "Pinned SHA256",
         "pinnedSha256": "Pinned SHA256",
+        "useSystemCA": "使用系統 CA",
         "shortId": "Short ID",
         "shortId": "Short ID",
         "sockopts": "Sockopts",
         "sockopts": "Sockopts",
         "keepAliveInterval": "keep alive 間隔",
         "keepAliveInterval": "keep alive 間隔",

+ 17 - 10
internal/xray/api.go

@@ -22,6 +22,7 @@ import (
 	wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
 	wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
 
 
 	"github.com/xtls/xray-core/app/proxyman/command"
 	"github.com/xtls/xray-core/app/proxyman/command"
+	"github.com/xtls/xray-core/app/router"
 	routerService "github.com/xtls/xray-core/app/router/command"
 	routerService "github.com/xtls/xray-core/app/router/command"
 	statsService "github.com/xtls/xray-core/app/stats/command"
 	statsService "github.com/xtls/xray-core/app/stats/command"
 	xnet "github.com/xtls/xray-core/common/net"
 	xnet "github.com/xtls/xray-core/common/net"
@@ -262,16 +263,7 @@ func (x *XrayAPI) ApplyRoutingConfig(routing []byte) error {
 		return common.NewError("xray RoutingServiceClient is not initialized")
 		return common.NewError("xray RoutingServiceClient is not initialized")
 	}
 	}
 
 
-	// Rules referencing geoip:/geosite: need the dat files; point xray-core's
-	// in-process loader at the panel's bin folder where they live.
-	ensureXrayAssetLocation()
-
-	routerConf := new(conf.RouterConfig)
-	if err := json.Unmarshal(routing, routerConf); err != nil {
-		logger.Debug("Failed to unmarshal routing config:", err)
-		return err
-	}
-	config, err := routerConf.Build()
+	config, err := buildReloadableRouting(routing)
 	if err != nil {
 	if err != nil {
 		logger.Debug("Failed to build routing config:", err)
 		logger.Debug("Failed to build routing config:", err)
 		return err
 		return err
@@ -287,6 +279,21 @@ func (x *XrayAPI) ApplyRoutingConfig(routing []byte) error {
 	return err
 	return err
 }
 }
 
 
+// buildReloadableRouting builds the rules and balancers RoutingService.AddRule swaps in.
+// The Lua routing script is fixed at core start, so its file is never resolved here.
+func buildReloadableRouting(routing []byte) (*router.Config, error) {
+	// Rules referencing geoip:/geosite: need the dat files; point xray-core's
+	// in-process loader at the panel's bin folder where they live.
+	ensureXrayAssetLocation()
+
+	routerConf := new(conf.RouterConfig)
+	if err := json.Unmarshal(routing, routerConf); err != nil {
+		return nil, err
+	}
+	routerConf.Script = ""
+	return routerConf.Build()
+}
+
 // BalancerInfo is the live state of one balancer inside the running core.
 // BalancerInfo is the live state of one balancer inside the running core.
 type BalancerInfo struct {
 type BalancerInfo struct {
 	Tag string `json:"tag"`
 	Tag string `json:"tag"`

+ 22 - 0
internal/xray/api_routing_test.go

@@ -0,0 +1,22 @@
+package xray
+
+import "testing"
+
+// xray-core 26.10.10 resolves routing.script from the loader's working directory, and the
+// running core keeps its script anyway: a script beside the xray binary failed every hot
+// rule change panel-side, turning it into a full restart and breaking the node bridge.
+func TestBuildReloadableRouting_IgnoresLuaScript(t *testing.T) {
+	t.Chdir(t.TempDir())
+	routing := []byte(`{"script":"route.lua","rules":[{"type":"field","ip":["10.0.0.0/8"],"outboundTag":"direct"}]}`)
+
+	config, err := buildReloadableRouting(routing)
+	if err != nil {
+		t.Fatalf("buildReloadableRouting: %v", err)
+	}
+	if config.Script != "" {
+		t.Fatalf("Script = %q, want it left to the running core", config.Script)
+	}
+	if len(config.Rule) != 1 || config.Rule[0].GetTag() != "direct" {
+		t.Fatalf("rules = %v, want the one rule to direct", config.Rule)
+	}
+}