mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-27 00:04:59 +03:00
feat(xray): update xray-core to v26.9.30 and adapt panel
Bump xtls/xray-core to b26a91de4f (v26.9.30) and the three binary pins (DockerInit.sh, release.yml Linux + Windows) in lockstep. No deleted symbols; the sing and sing-shadowsocks indirect deps drop out with the SS2022 rewrite. XDNS finalmask (#6718) replaced its string lists with objects: domains are {name, types, edns0, lenLimit, labelLimit} and client resolvers {type, settings.addr}. The loader no longer parses the old lists, so a single stored xdns mask keeps the whole core from starting. The new leaf package internal/util/maskcompat converts them: "name[:type]" becomes a domain and "name[:type]+udp://addr" a domain plus a udp resolver. A bare name maps to TXT, the type legacy clients queried by default, and each converted domain keeps the 1232-byte EDNS0 the old code always used (without it the server caps answers at 512). It runs from: - the XdnsFinalmaskObjectsFix seeder, over inbound streams, hosts, the xray template, the global sub-JSON mask and cached subscription outbounds; - inbound save (normalizeStreamSettings) and GetXrayConfig, for rows that never went through the seeder; - both link importers, since fm= from an older panel carries the lists. The finalmask form edits the object shape (every key needs a registered field, or the finalmask watch drops it on save) and lifts legacy masks on open. The udp-mask golden fixture moves to the object shape, which TestGoldenStreamFixturesBuildInXray now builds through the core. The wire format changed as well, so pre-upgrade clients need the new core. WireGuard outbound (#6771) dropped settings.domainStrategy and the remoteDNS "local" mode. The endpoint lookup now follows sockopt.domainStrategy and in-tunnel targets the outbound's targetStrategy. The old key is silently ignored, which undid the IPv4-first endpoint lookup the WARP outbound depends on (#5205), and "local" now panics the core at startup because remoteDNS goes through netip.MustParseAddr. The WireguardDomainStrategyFix seeder moves a stored family preference to both keys (a value already set wins) and turns "local" into targetStrategy; the outbound form lifts legacy rows the same way and drops its select, the WARP modal writes the new placement, and the inbound form loses a field the server never read. ValidateOutboundConfig now refuses a non-IP remoteDNS entry, which conf.Build() lets through, on template save and for outbound subscriptions. Noise finalmask items accept type "exp" (#6862), a tag expression. The form offers it for noise items only: header-custom items go through the core's PraseByteSlice, which refuses it. TUN gained autoSystemDnsToGateway (Linux) and autoSystemWfpBlockLeak (Windows). Both pass through the settings schema so a value set in JSON survives the next form save. MASQUE (inbound, outbound, transport) and the XDRIVE transport are new protocols the panel does not offer yet; their new loader refusals only cover configs the panel never generates. The FakeDNS IPv6 pool default, the SS2022 rewrite (same gRPC account; emails are now deduped case-insensitively, as the panel already does), the restored udphop interval default and the rest change no panel-facing config.
This commit is contained in:
@@ -272,11 +272,28 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses udp-mask byte-stably 1`
|
||||
{
|
||||
"settings": {
|
||||
"domains": [
|
||||
"example.com:txt",
|
||||
"example.org:a",
|
||||
{
|
||||
"edns0": 1232,
|
||||
"name": "example.com",
|
||||
"types": [
|
||||
16,
|
||||
],
|
||||
},
|
||||
{
|
||||
"edns0": 1232,
|
||||
"name": "example.org",
|
||||
"types": [
|
||||
1,
|
||||
],
|
||||
},
|
||||
],
|
||||
"resolvers": [
|
||||
"example.com:txt+udp://1.1.1.1:53",
|
||||
{
|
||||
"settings": {
|
||||
"addr": "1.1.1.1:53",
|
||||
},
|
||||
"type": "udp",
|
||||
},
|
||||
],
|
||||
},
|
||||
"type": "xdns",
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { FinalMaskField } from '@/lib/xray/forms/fields';
|
||||
|
||||
import { listSelectOptions, renderWithProviders } from './test-utils';
|
||||
|
||||
describe('FinalMaskForm item types', () => {
|
||||
// Only the noise mask parses tag expressions; header-custom items go through the
|
||||
// core's PraseByteSlice, which refuses "exp" and fails the whole config load.
|
||||
it('offers the exp type to noise items only', () => {
|
||||
renderWithProviders(
|
||||
<FinalMaskField
|
||||
network="kcp"
|
||||
protocol="vless"
|
||||
value={{
|
||||
tcp: [],
|
||||
udp: [
|
||||
{ type: 'noise', settings: { noise: [{ type: 'array', rand: '1-8', delay: '1-2' }] } },
|
||||
{ type: 'header-custom', settings: { client: [{ type: 'array', rand: 1 }] } },
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
// An opened dropdown stays in the DOM, so read header-custom's before noise's.
|
||||
expect(listSelectOptions('finalmask_udp_1_settings_client_0_type')).not.toContain('Expression');
|
||||
expect(listSelectOptions('finalmask_udp_0_settings_noise_0_type')).toContain('Expression');
|
||||
});
|
||||
});
|
||||
@@ -48,8 +48,11 @@
|
||||
{
|
||||
"type": "xdns",
|
||||
"settings": {
|
||||
"domains": ["example.com:txt", "example.org:a"],
|
||||
"resolvers": ["example.com:txt+udp://1.1.1.1:53"]
|
||||
"domains": [
|
||||
{ "name": "example.com", "types": [16], "edns0": 1232 },
|
||||
{ "name": "example.org", "types": [1], "edns0": 1232 }
|
||||
],
|
||||
"resolvers": [{ "type": "udp", "settings": { "addr": "1.1.1.1:53" } }]
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -58,6 +58,26 @@ describe('inboundFromDb', () => {
|
||||
expect((inbound.streamSettings as { security?: string })?.security).toBe('tls');
|
||||
});
|
||||
|
||||
// The settings parse strips keys the schema does not list, so a TUN option xray-core
|
||||
// 26.9.30 added and an admin entered as JSON would vanish on the next form save.
|
||||
it('keeps the TUN options xray-core 26.9.30 added', () => {
|
||||
const inbound = inboundFromDb({
|
||||
...BASE_DB_FIELDS,
|
||||
protocol: 'tun',
|
||||
settings: {
|
||||
name: 'xray0',
|
||||
gateway: ['10.0.0.1/16'],
|
||||
autoSystemDnsToGateway: true,
|
||||
autoSystemWfpBlockLeak: ['dns', 'misconfigtun'],
|
||||
},
|
||||
streamSettings: '',
|
||||
});
|
||||
expect(inbound.settings).toMatchObject({
|
||||
autoSystemDnsToGateway: true,
|
||||
autoSystemWfpBlockLeak: ['dns', 'misconfigtun'],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns schema-default settings for missing/empty fields without throwing', () => {
|
||||
const raw = {
|
||||
...BASE_DB_FIELDS,
|
||||
|
||||
@@ -312,6 +312,74 @@ describe('outbound-form-adapter: round-trip', () => {
|
||||
expect((unset.settings as { remoteDNS?: string[] }).remoteDNS).toBeUndefined();
|
||||
});
|
||||
|
||||
// xray-core 26.9.30 ignores wireguard's settings.domainStrategy and panics on remoteDNS
|
||||
// "local"; the endpoint lookup reads sockopt.domainStrategy, in-tunnel targets targetStrategy.
|
||||
it('wireguard lifts the removed domainStrategy and remoteDNS local where the core reads them', () => {
|
||||
const settings = {
|
||||
secretKey: 'YFVmTVCBsLxXJCe4i+jK8PgD3S6vUqfZ4Zl0JVNDfHA=',
|
||||
peers: [{ publicKey: 'pk', endpoint: 'engage.cloudflareclient.com:2408' }],
|
||||
};
|
||||
const warp = formValuesToWirePayload(
|
||||
rawOutboundToFormValues({
|
||||
protocol: 'wireguard',
|
||||
settings: { ...settings, domainStrategy: 'ForceIPv4v6' },
|
||||
}),
|
||||
);
|
||||
expect(warp.targetStrategy).toBe('ForceIPv4v6');
|
||||
expect(warp.streamSettings).toEqual({ sockopt: { domainStrategy: 'ForceIPv4v6' } });
|
||||
expect((warp.settings as Record<string, unknown>).domainStrategy).toBeUndefined();
|
||||
|
||||
const local = formValuesToWirePayload(
|
||||
rawOutboundToFormValues({
|
||||
protocol: 'wireguard',
|
||||
settings: { ...settings, remoteDNS: ['local'] },
|
||||
}),
|
||||
);
|
||||
expect(local.targetStrategy).toBe('ForceIP');
|
||||
expect((local.settings as Record<string, unknown>).remoteDNS).toBeUndefined();
|
||||
|
||||
const admin = formValuesToWirePayload(
|
||||
rawOutboundToFormValues({
|
||||
protocol: 'wireguard',
|
||||
targetStrategy: 'UseIPv6',
|
||||
streamSettings: { sockopt: { domainStrategy: 'UseIPv4' } },
|
||||
settings: { ...settings, domainStrategy: 'ForceIPv6' },
|
||||
}),
|
||||
);
|
||||
expect(admin.targetStrategy).toBe('UseIPv6');
|
||||
expect(admin.streamSettings).toEqual({ sockopt: { domainStrategy: 'UseIPv4' } });
|
||||
});
|
||||
|
||||
// The core dials a wireguard peer through its finalmask (noise "exp" was built for WARP),
|
||||
// so saving through the form must keep the masks next to sockopt instead of dropping them.
|
||||
it('wireguard keeps its finalmask on save and drops an empty one', () => {
|
||||
const settings = {
|
||||
secretKey: 'YFVmTVCBsLxXJCe4i+jK8PgD3S6vUqfZ4Zl0JVNDfHA=',
|
||||
peers: [{ publicKey: 'pk', endpoint: 'engage.cloudflareclient.com:2408' }],
|
||||
};
|
||||
const noise = {
|
||||
type: 'noise',
|
||||
settings: { noise: [{ type: 'exp', packet: '<b 0d0a0d0a><t>', delay: '1-3' }] },
|
||||
};
|
||||
const masked = formValuesToWirePayload(
|
||||
rawOutboundToFormValues({
|
||||
protocol: 'wireguard',
|
||||
settings,
|
||||
streamSettings: { sockopt: { mark: 255 }, finalmask: { tcp: [], udp: [noise] } },
|
||||
}),
|
||||
);
|
||||
expect(masked.streamSettings).toEqual({ sockopt: { mark: 255 }, finalmask: { udp: [noise] } });
|
||||
|
||||
const empty = formValuesToWirePayload(
|
||||
rawOutboundToFormValues({
|
||||
protocol: 'wireguard',
|
||||
settings,
|
||||
streamSettings: { sockopt: { mark: 255 }, finalmask: { tcp: [], udp: [] } },
|
||||
}),
|
||||
);
|
||||
expect(empty.streamSettings).toEqual({ sockopt: { mark: 255 } });
|
||||
});
|
||||
|
||||
it('dns rules normalize qType numeric strings, split domains, carry rCode', () => {
|
||||
const wire = {
|
||||
protocol: 'dns',
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { act, fireEvent } from '@testing-library/react';
|
||||
import { act, fireEvent, render } from '@testing-library/react';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import OutboundFormModal from '@/pages/xray/outbounds/OutboundFormModal';
|
||||
import {
|
||||
renderWithProviders,
|
||||
fieldLabels,
|
||||
listSelectOptions,
|
||||
chooseSelectOption,
|
||||
makeTestQueryClient,
|
||||
} from './test-utils';
|
||||
|
||||
function renderModal(outbound: Record<string, unknown> | null = null) {
|
||||
@@ -149,4 +152,74 @@ describe('OutboundFormModal', () => {
|
||||
};
|
||||
expect(payload.settings.reverse?.tag).toBe('r1');
|
||||
});
|
||||
|
||||
// xray-core 26.9.30 no longer parses xdns's string lists, so the mask editor lifts
|
||||
// them into objects on open rather than saving a config the core would refuse.
|
||||
it('saves a legacy xdns mask in the object shape', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const queryClient = makeTestQueryClient();
|
||||
const outbound = {
|
||||
protocol: 'vless',
|
||||
tag: 'dns-tunnel',
|
||||
settings: {
|
||||
vnext: [
|
||||
{
|
||||
address: 'example.com',
|
||||
port: 53,
|
||||
users: [{ id: 'c9f0c2d0-0000-4000-8000-000000000000', encryption: 'none' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
streamSettings: {
|
||||
network: 'kcp',
|
||||
security: 'none',
|
||||
kcpSettings: { mtu: 130, tti: 50 },
|
||||
finalmask: {
|
||||
udp: [{ type: 'xdns', settings: { resolvers: ['t.example.com+udp://8.8.8.8:53'] } }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const tree = (open: boolean) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<OutboundFormModal
|
||||
open={open}
|
||||
outbound={outbound}
|
||||
existingTags={[]}
|
||||
onClose={() => {}}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
// The panel keeps the modal mounted and flips `open`; mounting it already open lets
|
||||
// the add-mode transport seed replace the edited outbound's kcp stream with tcp.
|
||||
const { rerender } = render(tree(false));
|
||||
rerender(tree(true));
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
|
||||
const ok = document.querySelector('.ant-modal-footer .ant-btn-primary') as HTMLElement;
|
||||
await act(async () => {
|
||||
fireEvent.click(ok);
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
const payload = onConfirm.mock.calls[0][0] as {
|
||||
streamSettings: { finalmask: { udp: Array<{ type: string; settings: unknown }> } };
|
||||
};
|
||||
expect(payload.streamSettings.finalmask.udp).toEqual([
|
||||
{
|
||||
type: 'xdns',
|
||||
settings: {
|
||||
domains: [{ name: 't.example.com', types: [16], edns0: 1232 }],
|
||||
resolvers: [{ type: 'udp', settings: { addr: '8.8.8.8:53' } }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -224,6 +224,26 @@ describe('parseVlessLink — XHTTP advanced fields', () => {
|
||||
});
|
||||
|
||||
describe('parseVlessLink', () => {
|
||||
// A panel older than xray-core 26.9.30 shares xdns in the string lists the core no
|
||||
// longer parses, so an outbound imported verbatim would fail the whole config.
|
||||
it('upgrades a legacy xdns fm= mask to the object shape', () => {
|
||||
const fm = encodeURIComponent(
|
||||
JSON.stringify({
|
||||
udp: [{ type: 'xdns', settings: { resolvers: ['t.example.com+udp://8.8.8.8:53'] } }],
|
||||
}),
|
||||
);
|
||||
const out = parseVlessLink(
|
||||
`vless://11111111-2222-4333-8444-555555555555@srv:53?type=kcp&security=none&fm=${fm}#dns`,
|
||||
);
|
||||
const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as {
|
||||
udp: Array<{ settings: unknown }>;
|
||||
};
|
||||
expect(finalmask.udp[0].settings).toEqual({
|
||||
domains: [{ name: 't.example.com', types: [16], edns0: 1232 }],
|
||||
resolvers: [{ type: 'udp', settings: { addr: '8.8.8.8:53' } }],
|
||||
});
|
||||
});
|
||||
|
||||
it('parses a vless:// link with reality', () => {
|
||||
const link =
|
||||
'vless://11111111-2222-4333-8444-555555555555@srv.example:443' +
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { mergeWarpRotation } from '@/pages/xray/overrides/WarpModal';
|
||||
import { buildWarpOutbound, mergeWarpRotation } from '@/pages/xray/overrides/WarpModal';
|
||||
|
||||
const clientId = btoa(String.fromCharCode(1, 2, 3));
|
||||
|
||||
@@ -110,3 +110,14 @@ describe('mergeWarpRotation', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildWarpOutbound', () => {
|
||||
// xray-core 26.9.30 ignores wireguard's settings.domainStrategy, so the IPv4-first
|
||||
// endpoint lookup #5205 depends on has to travel in sockopt, where the core reads it.
|
||||
it('places the IPv4-first strategy where xray-core reads it', () => {
|
||||
const outbound = buildWarpOutbound({ private_key: 'secret' }, rotatedConfig()) ?? {};
|
||||
expect(outbound.streamSettings).toEqual({ sockopt: { domainStrategy: 'ForceIPv4v6' } });
|
||||
expect(outbound.targetStrategy).toBe('ForceIPv4v6');
|
||||
expect(outbound.settings).not.toHaveProperty('domainStrategy');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user