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 committed 2026-10-11 07:55:12 +02:00
1 parent 82c7711189
commit 73a028f52d
37 files changed
+553 -205

No files matched your search

+2 -2
View File
@@ -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
View File
@@ -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}"
@@ -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']}> <Form.Item label="Names" name={[domain.name, 'names']}>
<Input placeholder="t.example.com" /> <Select
mode="tags"
style={{ width: '100%' }}
tokenSeparators={[',']}
placeholder="t.example.com"
/>
</Form.Item> </Form.Item>
<Form.Item <Form.Item label="Record Types" name={[domain.name, 'types']}>
label="Record Types" <Select
name={[domain.name, 'types']} mode="multiple"
rules={[{ required: true, type: 'array', min: 1 }]} options={XDNS_RECORD_TYPE_OPTIONS}
> placeholder="TXT (client), all (server)"
<Select mode="multiple" options={XDNS_RECORD_TYPE_OPTIONS} /> />
</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 }} mode="tags"
options={[ style={{ width: '100%' }}
{ value: 'udp', label: 'UDP' }, tokenSeparators={[',', ' ']}
{ value: 'tcp', label: 'TCP' }, 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
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 }; 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(isLegacyDomain) && !rawResolvers.some(isLegacyResolver)) {
if (!rawDomains.some(isLegacy) && !rawResolvers.some(isLegacy)) {
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(); const names = Array.isArray(domain.names) ? (domain.names as unknown[]) : [];
if (key && listed.has(key)) return; for (const name of names) if (typeof name === 'string') listed.add(name.toLowerCase());
listed.add(key);
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); if (domain) addLegacyDomain(domain);
} else if (entry && typeof entry === 'object') { } else if (isRaw(entry)) {
addDomain(entry as Raw); 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;
@@ -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;
@@ -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
View File
@@ -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),
@@ -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": { "addrs": [
"addr": "1.1.1.1:53", "udp://1.1.1.1:53",
}, ],
"type": "udp",
}, },
], ],
}, },
@@ -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", "type": "xdns",
"settings": { "settings": {
"domains": [ "domains": [
{ "name": "example.com", "types": [16], "edns0": 1232 }, { "names": ["example.com"], "types": [16], "edns0": 1232 },
{ "name": "example.org", "types": [1], "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( const item = Array.from(document.querySelectorAll('.ant-form-item')).find(
(el) => (el) => (el.querySelector('.ant-form-item-label label')?.textContent ?? '').trim() === label,
(el.querySelector('.ant-form-item-label label')?.textContent ?? '').trim() === 'Sockopts',
); );
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 // xray-core 26.10.10 reads a 26.9.30 xdns mask as no domain at all, so the mask editor
// them into objects on open rather than saving a config the core would refuse. // lifts it to names/addrs on open and must keep those keys registered through the save.
it('saves a legacy xdns mask in the object shape', async () => { 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 }], domains: [{ names: ['t.example.com'], types: [16], edns0: 1232 }],
resolvers: [{ type: 'udp', settings: { addr: '8.8.8.8:53' } }], 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', () => { describe('parseVlessLink', () => {
// A panel older than xray-core 26.9.30 shares xdns in the string lists the core no // Older panels share xdns as string lists (pre-26.9.30, a load error) or as name/typed
// longer parses, so an outbound imported verbatim would fail the whole config. // resolver objects (26.9.30) that xray-core 26.10.10 reads as no domain at all.
it('upgrades a legacy xdns fm= mask to the object shape', () => { it.each([
const fm = encodeURIComponent( ['pre-26.9.30 string lists', { resolvers: ['t.example.com+udp://8.8.8.8:53'] }],
JSON.stringify({ [
udp: [{ type: 'xdns', settings: { 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 }], domains: [{ names: ['t.example.com'], types: [16], edns0: 1232 }],
resolvers: [{ type: 'udp', settings: { addr: '8.8.8.8:53' } }], resolvers: [{ addrs: ['udp://8.8.8.8:53'] }],
}); });
}); });
@@ -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
View File
@@ -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 !slices.Contains(seedersHistory, "XdnsFinalmaskNamesAddrsFix") {
if err := migrateXdnsFinalmaskObjects(); err != nil { 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 // migrateXdnsFinalmaskShape upgrades every stored xdns mask to the names/addrs shape
// xray-core 26.9.30 requires, wherever the panel keeps a finalmask. // xray-core 26.10.10 reads, wherever the panel keeps a finalmask.
func migrateXdnsFinalmaskObjects() error { 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
}) })
} }
@@ -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 // assertXdnsUpgraded fails unless the finalmask's xdns mask uses the names/addrs lists,
// shape xray-core 26.9.30 parses. // 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) got, err := json.Marshal(settings)
if len(domains) != 1 { if err != nil {
t.Fatalf("%s: domains = %v, want one entry", where, settings["domains"]) t.Fatalf("%s: marshal xdns settings: %v", where, err)
} }
domain, ok := domains[0].(map[string]any) const want = `{"domains":[{"edns0":1232,"names":["t.example.com"],"types":[16]}],"resolvers":[{"addrs":["udp://8.8.8.8:53"]}]}`
if !ok || domain["name"] != "t.example.com" { if string(got) != want {
t.Fatalf("%s: domain = %#v, want an object named t.example.com", where, domains[0]) 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
View File
@@ -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 // A panel on xray-core 26.9.30 shares xdns objects whose "name" and typed resolvers
// core no longer parses; imported verbatim, the outbound would fail the whole config. // 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://uuid@1.2.3.4:53?type=kcp&security=none&fm=" + fm + "#dns") res, err := ParseLink("vless://uuid@1.2.3.4: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
View File
@@ -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 // UpgradeLegacyXdns rewrites xdns masks into the names/addrs lists xray-core 26.10.10
// (#6718) no longer parses into its object lists; one legacy mask fails the whole config. // (#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) names, _ := domain["names"].([]any)
key = strings.ToLower(key) for _, name := range names {
if key != "" && listed[key] { if s, ok := name.(string); ok {
return 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) switch value := entry.(type) {
if !ok { 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 { func isLegacyXdnsDomain(value any) bool {
for _, value := range values { switch domain := value.(type) {
if _, ok := value.(string); ok { case string:
return true 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
View File
@@ -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 // coreXdnsView runs an xdns mask's settings through conf.XDNS, the loader the core
// the core calls at startup, so the test's verdict is the core's verdict. // calls at startup, and returns the domain names and resolvers the core would use.
func buildXdnsSettings(t *testing.T, settings any) error { 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() built, err := mask.Build()
return err 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 name string
mask string mask string
want string want string
wantDomains []string
wantResolvers []string
}{ }{
{ {
name: "bare server domain becomes TXT with the legacy EDNS0 size", name: "bare server domain becomes TXT with the legacy EDNS0 size",
mask: `{"type":"xdns","settings":{"domains":["t.example.com"]}}`, mask: `{"type":"xdns","settings":{"domains":["t.example.com"]}}`,
want: `{"domains":[{"edns0":1232,"name":"t.example.com","types":[16]}]}`, want: `{"domains":[{"edns0":1232,"names":["t.example.com"],"types":[16]}]}`,
wantDomains: []string{"t.example.com"},
}, },
{ {
name: "method suffixes map to their record types", 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"]}}`, 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]}]}`, 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", 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"]}}`, 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"}]}`, 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", 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"]}}`, 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"}]}`, 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", 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"]}}`, 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]}]}`, 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 { 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 { domains, resolvers, err := coreXdnsView(t, mask["settings"])
t.Fatal("the core accepted the legacy string shape; the upgrade is no longer needed") 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
View File
@@ -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 // canonicalizeLegacyXdnsMasks stores an xdns mask posted in a pre-26.10.10 shape in
// lists in the object shape the core parses, as GetXrayConfig would heal it anyway. // 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
@@ -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) got, err := json.Marshal(mask["settings"])
domains, _ := settings["domains"].([]any) if err != nil {
if len(domains) != 1 { t.Fatalf("marshal xdns settings: %v", err)
t.Fatalf("xdns domains = %v, want one", settings["domains"]) }
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 // An API client or an older panel's export can still post the 26.9.30 shape; stored as
// reach the sub links and the form in a shape the core no longer parses. // sent it would reach the sub links and the form in a shape the core silently empties.
func TestAddInbound_StoresXdnsMaskInObjectShape(t *testing.T) { 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) assertXdnsSettingsUpgraded(t, stream)
if !ok || domain["name"] != "t.example.com" {
t.Fatalf("stored xdns domain = %#v, want an object named t.example.com", firstXdnsDomain(t, stream))
}
} }
// A row that never went through the save path (restored backup, node sync, direct DB // 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
View File
@@ -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 // A pasted or restored template can still carry pre-#6258 XHTTP session keys or a
// still carry sessionPlacement/sessionKey; lift them too (same reason as // pre-26.10.10 xdns mask the core silently empties; heal them like the inbounds below.
// the per-inbound lift below). xrayConfig.OutboundConfigs = healOutboundStreams(xrayConfig.OutboundConfigs,
xrayConfig.OutboundConfigs = liftOutboundsXhttpSessionIDKeys(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 // healOutboundStreams applies every heal to each outbound's streamSettings, returning the
// outbound's streamSettings in the raw outbounds array. The original bytes are // original bytes when none changed anything so the hot-reload diff sees no edit.
// returned untouched when nothing needs lifting, so an unchanged config never func healOutboundStreams(raw json_util.RawMessage, heals ...func(stream map[string]any) bool) json_util.RawMessage {
// looks modified to the hot-reload diff.
func liftOutboundsXhttpSessionIDKeys(raw json_util.RawMessage) 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) { for _, heal := range heals {
changed = true 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"])
}
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 config, err := buildReloadableRouting(routing)
// 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()
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
View File
@@ -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)
}
}