diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 3afb3ef9d..2e277f000 100644
--- a/.github/workflows/release.yml
+++ b/.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.30/"
+ Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.10.10/"
if [ "${{ matrix.platform }}" == "amd64" ]; then
fetch ${Xray_URL}Xray-linux-64.zip
unzip Xray-linux-64.zip
@@ -278,7 +278,7 @@ jobs:
cd x-ui\bin
# 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"
Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
Remove-Item "Xray-windows-64.zip"
diff --git a/DockerInit.sh b/DockerInit.sh
index f488635a2..18fccad21 100755
--- a/DockerInit.sh
+++ b/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.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"
rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
mv xray "xray-linux-${FNAME}"
diff --git a/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx b/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx
index 4029e62dd..6efffd7f6 100644
--- a/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx
+++ b/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx
@@ -246,7 +246,7 @@ export default function FinalMaskForm({
const base = asPath(name);
// 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);
useEffect(() => {
if (migratedRef.current) return;
@@ -1280,7 +1280,7 @@ function XdnsSettings({ udpFieldName }: { udpFieldName: number }) {
size="small"
icon={}
aria-label={t('add')}
- onClick={() => add({ name: '', types: [16], edns0: XDNS_LEGACY_EDNS0 })}
+ onClick={() => add({ names: [], edns0: XDNS_LEGACY_EDNS0 })}
/>
{domains.map((domain, di) => (
@@ -1296,15 +1296,20 @@ function XdnsSettings({ udpFieldName }: { udpFieldName: number }) {
onKeyDown={activateOnKey(() => remove(domain.name))}
/>
-
-
+
+
-
-
+
+
@@ -1329,24 +1334,20 @@ function XdnsSettings({ udpFieldName }: { udpFieldName: number }) {
size="small"
icon={}
aria-label={t('add')}
- onClick={() => add({ type: 'udp', settings: { addr: '' } })}
+ onClick={() => add({ addrs: [] })}
/>
{resolvers.map((resolver, ri) => (
-
+
-
-
-
}
aria-label={t('remove')}
diff --git a/frontend/src/lib/xray/xdns-mask.ts b/frontend/src/lib/xray/xdns-mask.ts
index 73a1adefa..cd1cb7477 100644
--- a/frontend/src/lib/xray/xdns-mask.ts
+++ b/frontend/src/lib/xray/xdns-mask.ts
@@ -5,6 +5,10 @@ export const XDNS_LEGACY_EDNS0 = 1232;
const LEGACY_RECORD_TYPES: Record = { '': 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 {
let name = spec.trim();
let method = '';
@@ -16,46 +20,73 @@ function legacyDomain(spec: string): Raw | null {
name = name.replace(/^\.+|\.+$/g, '');
const type = LEGACY_RECORD_TYPES[method];
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.
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)) {
+ if (!rawDomains.some(isLegacyDomain) && !rawResolvers.some(isLegacyResolver)) {
return { next: settings, changed: false };
}
const domains: unknown[] = [];
const listed = new Set();
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);
};
+ const addLegacyDomain = (domain: Raw) => {
+ const [name] = domain.names as string[];
+ if (!listed.has(name.toLowerCase())) addDomain(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);
+ if (domain) addLegacyDomain(domain);
+ } else if (isRaw(entry)) {
+ addDomain(upgradeDomainObject(entry));
}
}
const resolvers: unknown[] = [];
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);
- 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;
diff --git a/frontend/src/pages/xray/dns/DnsServerModal.tsx b/frontend/src/pages/xray/dns/DnsServerModal.tsx
index d6899ea14..a2dacdc12 100644
--- a/frontend/src/pages/xray/dns/DnsServerModal.tsx
+++ b/frontend/src/pages/xray/dns/DnsServerModal.tsx
@@ -32,6 +32,7 @@ interface DnsServerModalProps {
const STRATEGIES = DnsQueryStrategySchema.options;
type DnsServerForm = {
+ id: string;
address: string;
port: number;
domains: string[];
@@ -50,6 +51,7 @@ type DnsServerForm = {
function defaultFormValues(): DnsServerForm {
return {
+ id: '',
address: 'localhost',
port: 53,
domains: [],
@@ -83,6 +85,7 @@ function valuesFromServer(server: DnsServerValue | null): DnsServerForm {
skipFallback: data?.skipFallback ?? server.skipFallback ?? false,
disableCache: data?.disableCache ?? server.disableCache ?? false,
finalQuery: data?.finalQuery ?? server.finalQuery ?? false,
+ id: data?.id ?? server.id ?? '',
tag: data?.tag ?? server.tag ?? '',
clientIP: data?.clientIP ?? server.clientIP ?? '',
serveStale: data?.serveStale ?? server.serveStale ?? false,
@@ -101,6 +104,7 @@ function valuesToWire(values: DnsServerForm): DnsServerValue {
values.skipFallback === false &&
values.disableCache === false &&
values.finalQuery === false &&
+ !values.id &&
!values.tag &&
!values.clientIP &&
values.serveStale === false &&
@@ -122,6 +126,7 @@ function valuesToWire(values: DnsServerForm): DnsServerValue {
timeoutMs: values.timeoutMs,
};
if (!isEncryptedDnsAddress(values.address)) out.port = values.port;
+ if (values.id) out.id = values.id;
if (values.tag) out.tag = values.tag;
if (values.clientIP) out.clientIP = values.clientIP;
return out as DnsServerValue;
diff --git a/frontend/src/pages/xray/outbounds/security/tls.tsx b/frontend/src/pages/xray/outbounds/security/tls.tsx
index 39c987974..5472c8f8c 100644
--- a/frontend/src/pages/xray/outbounds/security/tls.tsx
+++ b/frontend/src/pages/xray/outbounds/security/tls.tsx
@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next';
-import { Input, Select } from 'antd';
+import { Input, Select, Switch } from 'antd';
import { FormField } from '@/components/form/rhf';
@@ -37,6 +37,13 @@ export default function TlsForm() {
>
+
+
+
>
);
}
diff --git a/frontend/src/schemas/dns.ts b/frontend/src/schemas/dns.ts
index df7142d18..357765497 100644
--- a/frontend/src/schemas/dns.ts
+++ b/frontend/src/schemas/dns.ts
@@ -14,6 +14,7 @@ export function isEncryptedDnsAddress(address: string): boolean {
}
export const DnsServerObjectInnerSchema = z.object({
+ id: z.string().optional(),
address: z.string(),
port: PortSchema.optional(),
domains: z.array(z.string()).optional(),
@@ -54,6 +55,7 @@ export const DnsObjectSchema = z.object({
tag: z.string().optional(),
hosts: DnsHostsSchema.optional(),
servers: z.array(DnsServerEntrySchema).optional(),
+ script: z.string().optional(),
clientIp: z.string().optional(),
queryStrategy: DnsQueryStrategySchema.default('UseIP'),
disableCache: z.boolean().default(false),
diff --git a/frontend/src/test/__snapshots__/finalmask.test.ts.snap b/frontend/src/test/__snapshots__/finalmask.test.ts.snap
index b401c872a..41dc2f825 100644
--- a/frontend/src/test/__snapshots__/finalmask.test.ts.snap
+++ b/frontend/src/test/__snapshots__/finalmask.test.ts.snap
@@ -274,14 +274,18 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses udp-mask byte-stably 1`
"domains": [
{
"edns0": 1232,
- "name": "example.com",
+ "names": [
+ "example.com",
+ ],
"types": [
16,
],
},
{
"edns0": 1232,
- "name": "example.org",
+ "names": [
+ "example.org",
+ ],
"types": [
1,
],
@@ -289,10 +293,9 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses udp-mask byte-stably 1`
],
"resolvers": [
{
- "settings": {
- "addr": "1.1.1.1:53",
- },
- "type": "udp",
+ "addrs": [
+ "udp://1.1.1.1:53",
+ ],
},
],
},
diff --git a/frontend/src/test/dns-server-modal.test.tsx b/frontend/src/test/dns-server-modal.test.tsx
new file mode 100644
index 000000000..540c9adc9
--- /dev/null
+++ b/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(
+ {}}
+ 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' });
+ });
+});
diff --git a/frontend/src/test/golden/fixtures/finalmask/udp-mask.json b/frontend/src/test/golden/fixtures/finalmask/udp-mask.json
index 67f7f76c7..a477f996e 100644
--- a/frontend/src/test/golden/fixtures/finalmask/udp-mask.json
+++ b/frontend/src/test/golden/fixtures/finalmask/udp-mask.json
@@ -49,10 +49,10 @@
"type": "xdns",
"settings": {
"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"] }]
}
},
{
diff --git a/frontend/src/test/outbound-form-modal.test.tsx b/frontend/src/test/outbound-form-modal.test.tsx
index 359cf676f..4f12ef4ba 100644
--- a/frontend/src/test/outbound-form-modal.test.tsx
+++ b/frontend/src/test/outbound-form-modal.test.tsx
@@ -24,13 +24,12 @@ function renderModal(outbound: Record | null = null) {
);
}
-function toggleSockoptsSwitch() {
+function toggleSwitch(label: string) {
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');
- if (!control) throw new Error('Sockopts switch not found');
+ if (!control) throw new Error(`${label} switch not found`);
fireEvent.click(control);
}
@@ -74,7 +73,7 @@ describe('OutboundFormModal', () => {
// sockopt.domainStrategy, so freedom must show only one control for it.
it('hides the Transport sockopt strategy for freedom', () => {
renderModal({ protocol: 'freedom', tag: 'direct', settings: {} });
- toggleSockoptsSwitch();
+ toggleSwitch('Sockopts');
expect(fieldLabels()).toContain('Sockopts');
expect(fieldLabels()).not.toContain('Domain Strategy');
@@ -83,7 +82,7 @@ describe('OutboundFormModal', () => {
it('keeps the Transport sockopt strategy for protocols without a card field', () => {
renderModal({ protocol: 'vless', tag: 'proxy', settings: {} });
- toggleSockoptsSwitch();
+ toggleSwitch('Sockopts');
expect(fieldLabels()).toContain('Domain Strategy');
});
@@ -153,9 +152,9 @@ 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 () => {
+ // 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 queryClient = makeTestQueryClient();
const outbound = {
@@ -175,7 +174,15 @@ describe('OutboundFormModal', () => {
security: 'none',
kcpSettings: { mtu: 130, tti: 50 },
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',
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) => (
+
+
+ {}}
+ onConfirm={onConfirm}
+ />
+
+
+ );
+ 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,
+ });
+ });
});
diff --git a/frontend/src/test/outbound-link-parser.test.ts b/frontend/src/test/outbound-link-parser.test.ts
index 6a69a6dc7..5de0b78d1 100644
--- a/frontend/src/test/outbound-link-parser.test.ts
+++ b/frontend/src/test/outbound-link-parser.test.ts
@@ -225,14 +225,19 @@ 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'] } }],
- }),
- );
+ // 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(
`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 }>;
};
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'] }],
});
});
diff --git a/frontend/src/test/use-xray-setting.test.tsx b/frontend/src/test/use-xray-setting.test.tsx
index 5ec5edf2f..34dbe252c 100644
--- a/frontend/src/test/use-xray-setting.test.tsx
+++ b/frontend/src/test/use-xray-setting.test.tsx
@@ -49,6 +49,32 @@ describe('useXraySetting', () => {
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 }) => (
+ {children}
+ );
+ 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> };
+ };
+ 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 () => {
const payload = xrayPayload({ outboundTestUrl: 'https://www.google.com/generate_204' });
vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => {
diff --git a/internal/database/db.go b/internal/database/db.go
index 08ce9e3ed..25bfa95a7 100644
--- a/internal/database/db.go
+++ b/internal/database/db.go
@@ -1282,7 +1282,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", "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 {
if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
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
}
}
@@ -1928,9 +1928,9 @@ func strategyIsSet(value any) bool {
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 {
var inbounds []model.Inbound
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
})
}
diff --git a/internal/database/xdns_finalmask_migration_test.go b/internal/database/xdns_finalmask_migration_test.go
index 0f3d08715..469037ec6 100644
--- a/internal/database/xdns_finalmask_migration_test.go
+++ b/internal/database/xdns_finalmask_migration_test.go
@@ -7,10 +7,12 @@ import (
"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) {
t.Helper()
fm, _ := finalmask.(map[string]any)
@@ -20,13 +22,13 @@ func assertXdnsUpgraded(t *testing.T, where string, finalmask any) {
}
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"])
+ 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 {
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 {
t.Fatalf("clear seeder history: %v", err)
}
diff --git a/internal/util/link/outbound_test.go b/internal/util/link/outbound_test.go
index 08de2b3ad..12dc34a46 100644
--- a/internal/util/link/outbound_test.go
+++ b/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) {
- 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://uuid@1.2.3.4:53?type=kcp&security=none&fm=" + fm + "#dns")
if err != nil {
t.Fatalf("parse vless with fm: %v", err)
@@ -100,7 +100,7 @@ func TestParseLink_UpgradesLegacyXdnsFinalMask(t *testing.T) {
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"}]}`
+ 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 {
t.Fatalf("imported finalmask\n got: %s\nwant: %s", got, want)
}
diff --git a/internal/util/maskcompat/xdns.go b/internal/util/maskcompat/xdns.go
index 6a2091e51..82b4c9a0b 100644
--- a/internal/util/maskcompat/xdns.go
+++ b/internal/util/maskcompat/xdns.go
@@ -1,6 +1,9 @@
package maskcompat
-import "strings"
+import (
+ "slices"
+ "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.
@@ -8,8 +11,8 @@ 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.
+// 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 {
fm, _ := finalmask.(map[string]any)
masks, _ := fm["udp"].([]any)
@@ -31,48 +34,56 @@ func UpgradeLegacyXdns(finalmask any) bool {
func upgradeLegacyXdnsSettings(settings map[string]any) bool {
rawDomains, _ := settings["domains"].([]any)
rawResolvers, _ := settings["resolvers"].([]any)
- if !hasLegacyXdnsEntry(rawDomains) && !hasLegacyXdnsEntry(rawResolvers) {
+ if !slices.ContainsFunc(rawDomains, isLegacyXdnsDomain) && !slices.ContainsFunc(rawResolvers, isLegacyXdnsResolver) {
return false
}
domains := make([]any, 0, len(rawDomains))
listed := map[string]bool{}
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)
}
+ addLegacyDomain := func(domain map[string]any) {
+ if name, _ := domain["names"].([]any)[0].(string); !listed[strings.ToLower(name)] {
+ addDomain(domain)
+ }
+ }
for _, entry := range rawDomains {
switch value := entry.(type) {
case map[string]any:
- addDomain(value)
+ addDomain(upgradeXdnsDomainObject(value))
case string:
if domain, ok := legacyXdnsDomain(value); ok {
- addDomain(domain)
+ addLegacyDomain(domain)
}
}
}
resolvers := make([]any, 0, len(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)
- 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 {
@@ -83,6 +94,40 @@ func upgradeLegacyXdnsSettings(settings map[string]any) bool {
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.
func legacyXdnsDomain(spec string) (map[string]any, bool) {
name, method := strings.TrimSpace(spec), ""
@@ -94,14 +139,27 @@ func legacyXdnsDomain(spec string) (map[string]any, bool) {
if name == "" || !known {
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
}
diff --git a/internal/util/maskcompat/xdns_test.go b/internal/util/maskcompat/xdns_test.go
index 04b365209..2f91c392c 100644
--- a/internal/util/maskcompat/xdns_test.go
+++ b/internal/util/maskcompat/xdns_test.go
@@ -2,14 +2,16 @@ package maskcompat
import (
"encoding/json"
+ "slices"
"testing"
"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()
raw, err := json.Marshal(settings)
if err != nil {
@@ -17,42 +19,75 @@ func buildXdnsSettings(t *testing.T, settings any) error {
}
var mask conf.XDNS
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) {
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,"name":"a.example.com","types":[1]},{"edns0":1232,"name":"q.example.com","types":[28]},{"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,"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,"name":"t.example.com","types":[1]}],"resolvers":[{"settings":{"addr":"8.8.8.8:53"},"type":"udp"}]}`,
+ 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: "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: "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: "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: "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: "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: "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 {
@@ -61,8 +96,9 @@ func TestUpgradeLegacyXdns(t *testing.T) {
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")
+ 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}}
if !UpgradeLegacyXdns(finalmask) {
@@ -75,21 +111,25 @@ func TestUpgradeLegacyXdns(t *testing.T) {
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 {
+ domains, resolvers, err = coreXdnsView(t, mask["settings"])
+ if err != nil {
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) {
- 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
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")
+ t.Fatal("UpgradeLegacyXdns rewrote a mask that is already in the names/addrs shape")
}
var want map[string]any
_ = json.Unmarshal([]byte(current), &want)
diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go
index 0e61d8914..ab4408c44 100644
--- a/internal/web/service/inbound.go
+++ b/internal/web/service/inbound.go
@@ -666,8 +666,8 @@ func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) {
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 {
if streamSettings == "" {
return streamSettings
diff --git a/internal/web/service/inbound_finalmask_xdns_test.go b/internal/web/service/inbound_finalmask_xdns_test.go
index 256c72c7a..fe1d0a049 100644
--- a/internal/web/service/inbound_finalmask_xdns_test.go
+++ b/internal/web/service/inbound_finalmask_xdns_test.go
@@ -8,9 +8,13 @@ import (
"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()
finalmask, _ := stream["finalmask"].(map[string]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"])
}
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)
in := &model.Inbound{
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 {
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
-// 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) {
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)
@@ -84,8 +77,39 @@ func TestGetXrayConfig_UpgradesLegacyXdnsMask(t *testing.T) {
if err := json.Unmarshal(raw, &emitted); err != nil {
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))
return
}
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")
+}
diff --git a/internal/web/service/xray.go b/internal/web/service/xray.go
index cd297c7c8..e680ec380 100644
--- a/internal/web/service/xray.go
+++ b/internal/web/service/xray.go
@@ -180,10 +180,10 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
xrayConfig.API = ensureAPIServices(xrayConfig.API)
xrayConfig.Policy = ensureStatsPolicy(xrayConfig.Policy)
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;
// the core has no amneziawg proxy and would reject the raw entry.
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)
}
- // 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"])
// 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
}
-// 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 {
return raw
}
@@ -1727,8 +1725,10 @@ func liftOutboundsXhttpSessionIDKeys(raw json_util.RawMessage) json_util.RawMess
changed := false
for _, ob := range outbounds {
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
}
+
+func upgradeStreamLegacyXdns(stream map[string]any) bool {
+ return maskcompat.UpgradeLegacyXdns(stream["finalmask"])
+}
diff --git a/internal/web/service/xray_xhttp_session_test.go b/internal/web/service/xray_xhttp_session_test.go
index a122f99ca..800009481 100644
--- a/internal/web/service/xray_xhttp_session_test.go
+++ b/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"}}}]`)
- out := liftOutboundsXhttpSessionIDKeys(raw)
+ out := healOutboundStreams(raw, liftXhttpSessionIDKeys)
var parsed []map[string]any
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).
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)
}
}
diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json
index dd9d01fa1..123422e6a 100644
--- a/internal/web/translation/ar-EG.json
+++ b/internal/web/translation/ar-EG.json
@@ -1929,6 +1929,7 @@
"serverNamePlaceholder": "اسم الخادم",
"verifyPeerName": "التحقق من اسم peer",
"pinnedSha256": "SHA256 مثبت",
+ "useSystemCA": "استخدام CA النظام",
"shortId": "Short ID",
"sockopts": "Sockopts",
"keepAliveInterval": "فاصل keep alive",
diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json
index 1727a9517..234d57df0 100644
--- a/internal/web/translation/en-US.json
+++ b/internal/web/translation/en-US.json
@@ -2047,6 +2047,7 @@
"serverNamePlaceholder": "server name",
"verifyPeerName": "Verify peer name",
"pinnedSha256": "Pinned SHA256",
+ "useSystemCA": "Use system CA",
"shortId": "Short ID",
"sockopts": "Sockopts",
"keepAliveInterval": "Keep alive interval",
diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json
index ed9e15db5..d2a44b80a 100644
--- a/internal/web/translation/es-ES.json
+++ b/internal/web/translation/es-ES.json
@@ -1929,6 +1929,7 @@
"serverNamePlaceholder": "nombre del servidor",
"verifyPeerName": "Verificar nombre del peer",
"pinnedSha256": "SHA256 pinned",
+ "useSystemCA": "Usar CA del sistema",
"shortId": "Short ID",
"sockopts": "Sockopts",
"keepAliveInterval": "Intervalo keep alive",
diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json
index 93244fce3..6a021977a 100644
--- a/internal/web/translation/fa-IR.json
+++ b/internal/web/translation/fa-IR.json
@@ -1929,6 +1929,7 @@
"serverNamePlaceholder": "نام سرور",
"verifyPeerName": "تایید نام Peer",
"pinnedSha256": "SHA256 پینشده",
+ "useSystemCA": "استفاده از CA سیستم",
"shortId": "Short ID",
"sockopts": "Sockopts",
"keepAliveInterval": "بازه Keep alive",
diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json
index e1a79c6e1..c02b70877 100644
--- a/internal/web/translation/id-ID.json
+++ b/internal/web/translation/id-ID.json
@@ -1929,6 +1929,7 @@
"serverNamePlaceholder": "nama server",
"verifyPeerName": "Verifikasi nama peer",
"pinnedSha256": "SHA256 pinned",
+ "useSystemCA": "Gunakan CA sistem",
"shortId": "Short ID",
"sockopts": "Sockopts",
"keepAliveInterval": "Interval keep alive",
diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json
index 1a5d8d5f5..35318ddf3 100644
--- a/internal/web/translation/ja-JP.json
+++ b/internal/web/translation/ja-JP.json
@@ -1929,6 +1929,7 @@
"serverNamePlaceholder": "サーバー名",
"verifyPeerName": "peer 名を検証",
"pinnedSha256": "Pinned SHA256",
+ "useSystemCA": "システムの CA を使用",
"shortId": "Short ID",
"sockopts": "Sockopts",
"keepAliveInterval": "keep alive 間隔",
diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json
index be1a63565..d37328b0b 100644
--- a/internal/web/translation/pt-BR.json
+++ b/internal/web/translation/pt-BR.json
@@ -1929,6 +1929,7 @@
"serverNamePlaceholder": "nome do servidor",
"verifyPeerName": "Verificar nome do peer",
"pinnedSha256": "SHA256 pinned",
+ "useSystemCA": "Usar CA do sistema",
"shortId": "Short ID",
"sockopts": "Sockopts",
"keepAliveInterval": "Intervalo keep alive",
diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json
index 22aa20fbb..37eaef461 100644
--- a/internal/web/translation/ru-RU.json
+++ b/internal/web/translation/ru-RU.json
@@ -1929,6 +1929,7 @@
"serverNamePlaceholder": "имя сервера",
"verifyPeerName": "Проверять имя peer",
"pinnedSha256": "Pinned SHA256",
+ "useSystemCA": "Использовать системные CA",
"shortId": "Short ID",
"sockopts": "Sockopts",
"keepAliveInterval": "Интервал keep alive",
diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json
index c38277486..da96b7962 100644
--- a/internal/web/translation/tr-TR.json
+++ b/internal/web/translation/tr-TR.json
@@ -1929,6 +1929,7 @@
"serverNamePlaceholder": "sunucu adı",
"verifyPeerName": "Peer Adını Doğrula",
"pinnedSha256": "Sabitlenmiş SHA256",
+ "useSystemCA": "Sistem CA'sını kullan",
"shortId": "Kısa Kimlik",
"sockopts": "Sockopts",
"keepAliveInterval": "Keep Alive Aralığı",
diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json
index 756b3b6de..0c91f0eba 100644
--- a/internal/web/translation/uk-UA.json
+++ b/internal/web/translation/uk-UA.json
@@ -1929,6 +1929,7 @@
"serverNamePlaceholder": "ім'я сервера",
"verifyPeerName": "Перевіряти ім'я peer",
"pinnedSha256": "Pinned SHA256",
+ "useSystemCA": "Використовувати системні CA",
"shortId": "Short ID",
"sockopts": "Sockopts",
"keepAliveInterval": "Інтервал keep alive",
diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json
index b03c668b7..4edbb142e 100644
--- a/internal/web/translation/vi-VN.json
+++ b/internal/web/translation/vi-VN.json
@@ -1929,6 +1929,7 @@
"serverNamePlaceholder": "tên máy chủ",
"verifyPeerName": "Xác minh tên peer",
"pinnedSha256": "SHA256 pinned",
+ "useSystemCA": "Dùng CA của hệ thống",
"shortId": "Short ID",
"sockopts": "Sockopts",
"keepAliveInterval": "Khoảng keep alive",
diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json
index 32e54711c..6ab6b005f 100644
--- a/internal/web/translation/zh-CN.json
+++ b/internal/web/translation/zh-CN.json
@@ -1929,6 +1929,7 @@
"serverNamePlaceholder": "服务器名",
"verifyPeerName": "验证 peer 名称",
"pinnedSha256": "Pinned SHA256",
+ "useSystemCA": "使用系统 CA",
"shortId": "Short ID",
"sockopts": "Sockopts",
"keepAliveInterval": "keep alive 间隔",
diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json
index 13c6152f9..6c7fdccd3 100644
--- a/internal/web/translation/zh-TW.json
+++ b/internal/web/translation/zh-TW.json
@@ -1929,6 +1929,7 @@
"serverNamePlaceholder": "伺服器名稱",
"verifyPeerName": "驗證 peer 名稱",
"pinnedSha256": "Pinned SHA256",
+ "useSystemCA": "使用系統 CA",
"shortId": "Short ID",
"sockopts": "Sockopts",
"keepAliveInterval": "keep alive 間隔",
diff --git a/internal/xray/api.go b/internal/xray/api.go
index b43e4f8b1..b49b0588b 100644
--- a/internal/xray/api.go
+++ b/internal/xray/api.go
@@ -22,6 +22,7 @@ import (
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
"github.com/xtls/xray-core/app/proxyman/command"
+ "github.com/xtls/xray-core/app/router"
routerService "github.com/xtls/xray-core/app/router/command"
statsService "github.com/xtls/xray-core/app/stats/command"
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")
}
- // 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 {
logger.Debug("Failed to build routing config:", err)
return err
@@ -287,6 +279,21 @@ func (x *XrayAPI) ApplyRoutingConfig(routing []byte) error {
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.
type BalancerInfo struct {
Tag string `json:"tag"`
diff --git a/internal/xray/api_routing_test.go b/internal/xray/api_routing_test.go
new file mode 100644
index 000000000..cefb3c064
--- /dev/null
+++ b/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)
+ }
+}