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:
MHSanaei
2026-09-30 17:27:10 +02:00
parent 99047c0a63
commit 62423cacd1
38 changed files with 1487 additions and 165 deletions
@@ -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",
+29
View File
@@ -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" } }]
}
},
{
+20
View File
@@ -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',
+74 -1
View File
@@ -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' +
+12 -1
View File
@@ -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');
});
});