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.
This commit is contained in:
MHSanaei
2026-10-11 07:55:12 +02:00
parent 82c7711189
commit 73a028f52d
37 changed files with 553 additions and 205 deletions
@@ -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={<PlusOutlined />}
aria-label={t('add')}
onClick={() => add({ name: '', types: [16], edns0: XDNS_LEGACY_EDNS0 })}
onClick={() => add({ names: [], edns0: XDNS_LEGACY_EDNS0 })}
/>
</Form.Item>
{domains.map((domain, di) => (
@@ -1296,15 +1296,20 @@ function XdnsSettings({ udpFieldName }: { udpFieldName: number }) {
onKeyDown={activateOnKey(() => remove(domain.name))}
/>
</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
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 label="EDNS0" name={[domain.name, 'edns0']}>
<InputNumber min={512} max={4096} placeholder="off" />
@@ -1329,24 +1334,20 @@ function XdnsSettings({ udpFieldName }: { udpFieldName: number }) {
size="small"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => add({ type: 'udp', settings: { addr: '' } })}
onClick={() => add({ addrs: [] })}
/>
</Form.Item>
{resolvers.map((resolver, ri) => (
<Form.Item key={resolver.key} label={`Resolver ${ri + 1}`}>
<Space.Compact block>
<Form.Item name={[resolver.name, 'type']} noStyle>
<Form.Item name={[resolver.name, 'addrs']} noStyle>
<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 name={[resolver.name, 'settings', 'addr']} noStyle>
<Input placeholder="8.8.8.8:53" />
</Form.Item>
<Button
icon={<DeleteOutlined />}
aria-label={t('remove')}
+49 -18
View File
@@ -5,6 +5,10 @@ export const XDNS_LEGACY_EDNS0 = 1232;
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 {
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<string>();
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;
@@ -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;
@@ -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() {
>
<Input placeholder="base64 SHA256" />
</FormField>
<FormField
label={t('pages.xray.outboundForm.useSystemCA')}
name={['streamSettings', 'tlsSettings', 'useSystemCA']}
valueProp="checked"
>
<Switch />
</FormField>
</>
);
}
+2
View File
@@ -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),
@@ -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",
],
},
],
},
@@ -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' });
});
});
@@ -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"] }]
}
},
{
+80 -12
View File
@@ -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(
(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) => (
<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
View File
@@ -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'] }],
});
});
@@ -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 }) => (
<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 () => {
const payload = xrayPayload({ outboundTestUrl: 'https://www.google.com/generate_204' });
vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => {