feat(xray): update xray-core to v26.7.28 and adapt panel

Bump xtls/xray-core to 5ca6f4b7d4dc (v26.7.28) and move the three binary
pins (DockerInit.sh, the Linux and Windows URLs in release.yml) in lockstep
so the in-process conf.Build() validation and the child binary agree.

XMC finalmask (#6487) is the breaking change. The mask's `usernames` string
list is gone, replaced by a required `profiles` array whose entries each need
a 3-16 character [A-Za-z0-9_] username, a parseable UUID and both Mojang
texture fields; the "default to Dream when empty" fallback was removed, so an
xmc mask saved by an older panel now fails to build and takes the whole
config down with it rather than degrading one inbound.

The textures are a signed blob only Mojang's session server can issue, so a
legacy username cannot be upgraded automatically. The panel now:

- rejects an incomplete xmc mask at save time (AddInbound/UpdateInbound),
  pointing at the specific field that is missing;
- drops only the offending mask when generating the core config, for rows
  that never went through the form (upgrade, node sync, restored backup,
  direct DB edit), warning which inbound lost its obfuscation instead of
  leaving every inbound offline;
- carries legacy usernames into profile stubs in the finalmask form so the
  operator keeps their player names and sees exactly what still needs
  filling in, and edits profiles through a list editor.

No destructive DB migration: unlike the removed shadowsocks ciphers there is
no valid replacement to rewrite to, and dropping the mask from stored rows
would discard the operator's hostname and password for config they can still
repair. The generation-time strip already prevents the startup failure.

Also track the core's xmux maxConnections fallback, lowered from 6 to 3 for
anti-TSPU, in the fresh-XMUX seed so a new panel config matches what the core
would pick on its own.

TUN gained a `desc` key and random utunN naming, but the Go validator no
longer accepts TUN inbounds and the panel only renders legacy saved rows, so
nothing there needs adapting. The remaining commits are REALITY log-warning
wording, gRPC/XHTTP localAddr accuracy and a routing tweak, none of which
change the JSON config surface.

Tests cross-check the panel's profile predicate against conf.XMCProfile.Build()
so a future core release that tightens or relaxes the rules fails loudly
rather than silently emitting configs the core refuses to start on.
This commit is contained in:
Sanaei
2026-07-28 13:14:06 +02:00
parent fd17255f1d
commit 7f7b7e16a4
12 changed files with 541 additions and 26 deletions
@@ -82,12 +82,43 @@ function defaultTcpMaskSettings(type: string): Record<string, unknown> {
case 'header-custom':
return { clients: [], servers: [] };
case 'xmc':
return { hostname: '', usernames: [], password: RandomUtil.randomLowerAndNum(16) };
return { hostname: '', profiles: [defaultXmcProfile()], password: RandomUtil.randomLowerAndNum(16) };
default:
return {};
}
}
function defaultXmcProfile(): Record<string, unknown> {
return { username: '', uuid: '', texturesValue: '', texturesSignature: '' };
}
// xray-core #6487 replaced the xmc mask's `usernames` string list with
// `profiles` objects carrying a Mojang-signed session profile, and dropped the
// "default to Dream" fallback so at least one complete profile is now
// mandatory. The signature can only come from Mojang's session server, so a
// legacy username cannot be upgraded automatically — carry it into a profile
// stub instead, which keeps the operator's player names visible and leaves the
// per-field validators pointing at exactly what still has to be filled in.
export function migrateXmcSettings(settings: Record<string, unknown>): { next: Record<string, unknown>; changed: boolean } {
const out: Record<string, unknown> = { ...settings };
let changed = false;
if (!Array.isArray(out.profiles) && Array.isArray(out.usernames)) {
out.profiles = out.usernames
.filter((name): name is string => typeof name === 'string' && name.trim() !== '')
.map((name) => ({ ...defaultXmcProfile(), username: name }));
changed = true;
}
if ('usernames' in out) {
delete out.usernames;
changed = true;
}
if (!Array.isArray(out.profiles)) {
out.profiles = [];
changed = true;
}
return { next: out, changed };
}
// xray-core #6334 replaced a fragment mask's single `length`/`delay` ranges
// with `lengths`/`delays` arrays (the singular keys remain in core only as a
// fallback). Lift any legacy singular value into a one-element array so the
@@ -171,8 +202,8 @@ function defaultUdpHop(): Record<string, unknown> {
export default function FinalMaskForm({ name, network, protocol, form, showAll = false }: FinalMaskFormProps) {
const base = asPath(name);
// Migrate legacy single-range fragment masks to the per-segment arrays once
// on mount so configs saved before #6334 render in the list UI.
// Migrate legacy TCP mask shapes once on mount so configs saved before
// #6334 (fragment ranges) and #6487 (xmc profiles) render in the list UI.
const migratedRef = useRef(false);
useEffect(() => {
if (migratedRef.current) return;
@@ -183,8 +214,12 @@ export default function FinalMaskForm({ name, network, protocol, form, showAll =
const next = tcp.map((mask) => {
if (!mask || typeof mask !== 'object') return mask;
const m = mask as Record<string, unknown>;
if (m.type !== 'fragment' || !m.settings || typeof m.settings !== 'object') return mask;
const { next: migrated, changed } = migrateFragmentSettings(m.settings as Record<string, unknown>);
if (m.type !== 'fragment' && m.type !== 'xmc') return mask;
if (!m.settings || typeof m.settings !== 'object') return mask;
const settings = m.settings as Record<string, unknown>;
const { next: migrated, changed } = m.type === 'fragment'
? migrateFragmentSettings(settings)
: migrateXmcSettings(settings);
if (!changed) return mask;
anyChanged = true;
return { ...m, settings: migrated };
@@ -380,13 +415,7 @@ function TcpMaskItem({
<Form.Item label="Hostname" name={[fieldName, 'settings', 'hostname']}>
<Input placeholder="Server address mimicked in the handshake" />
</Form.Item>
<Form.Item
label="Usernames"
name={[fieldName, 'settings', 'usernames']}
extra="Player names offered to probes; core defaults to Dream when empty."
>
<Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} />
</Form.Item>
<XmcProfilesList tcpFieldName={fieldName} />
<Form.Item label="Password" required>
<Space.Compact block>
<Form.Item
@@ -528,6 +557,92 @@ function getDeep(obj: unknown, path: (string | number)[]): unknown {
return cur;
}
// Mojang hands the profile UUID back undashed from the session server and
// dashed from most other endpoints; xray-core parses either, so accept both
// rather than forcing the operator to reformat what they pasted.
const XMC_UUID_PATTERN = /^(?:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{32})$/;
const XMC_USERNAME_PATTERN = /^[A-Za-z0-9_]{3,16}$/;
function validateXmcUsername(_rule: unknown, value: unknown): Promise<void> {
if (typeof value === 'string' && XMC_USERNAME_PATTERN.test(value)) return Promise.resolve();
return Promise.reject(new Error('3-16 characters, letters/digits/underscore only'));
}
function validateXmcUuid(_rule: unknown, value: unknown): Promise<void> {
if (typeof value === 'string' && XMC_UUID_PATTERN.test(value.trim())) return Promise.resolve();
return Promise.reject(new Error('Enter the profile UUID (dashed or 32 hex characters)'));
}
// Each mask needs at least one fully signed profile since xray-core #6487 —
// an empty or partial list makes the core reject the whole config, so the
// panel blocks the save here rather than letting the backend drop the mask.
function XmcProfilesList({ tcpFieldName }: { tcpFieldName: number }) {
const { t } = useTranslation();
return (
<Form.List name={[tcpFieldName, 'settings', 'profiles']}>
{(profiles, { add, remove }) => (
<>
<Form.Item
label="Profiles"
extra="Signed Minecraft session profiles; resolve the UUID by username, then fetch the profile with unsigned=false."
>
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => add(defaultXmcProfile())}
/>
</Form.Item>
{profiles.map((profile, idx) => (
<div key={profile.key}>
<Divider style={{ margin: 0 }}>
Profile {idx + 1}
<DeleteOutlined
className="danger-icon"
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={() => remove(profile.name)}
onKeyDown={activateOnKey(() => remove(profile.name))}
/>
</Divider>
<Form.Item
label="Username"
name={[profile.name, 'username']}
rules={[{ validator: validateXmcUsername }]}
>
<Input placeholder="Notch" />
</Form.Item>
<Form.Item
label="UUID"
name={[profile.name, 'uuid']}
rules={[{ validator: validateXmcUuid }]}
>
<Input placeholder="069a79f4-44e9-4726-a5be-fca90e38aaf5" />
</Form.Item>
<Form.Item
label="Textures Value"
name={[profile.name, 'texturesValue']}
rules={[{ required: true, message: 'Textures value is required' }]}
>
<Input.TextArea autoSize={{ minRows: 2, maxRows: 4 }} placeholder="Base64 value from the session profile" />
</Form.Item>
<Form.Item
label="Textures Signature"
name={[profile.name, 'texturesSignature']}
rules={[{ required: true, message: 'Textures signature is required' }]}
>
<Input.TextArea autoSize={{ minRows: 2, maxRows: 4 }} placeholder="Base64 signature from the session profile" />
</Form.Item>
</div>
))}
</>
)}
</Form.List>
);
}
function HeaderCustomGroups({
tcpFieldName, form, absoluteSettingsPath,
}: {
@@ -31,12 +31,14 @@ export const XHttpXmuxSchema = z.object({
export type XHttpXmux = z.infer<typeof XHttpXmuxSchema>;
// Seed for freshly enabling XMUX on a config that had no xmux block:
// mirrors xray-core v26.6.27's own anti-RKN maxConnections=6 fallback
// rather than the concurrency strategy.
// mirrors xray-core's own maxConnections fallback rather than the
// concurrency strategy. v26.7.28 lowered that fallback from 6 to 3 for
// anti-TSPU, so track it here to keep a fresh panel config matching what
// the core would have picked on its own.
export const XMUX_FRESH_DEFAULTS: XHttpXmux = {
...XHttpXmuxSchema.parse({}),
maxConcurrency: '',
maxConnections: 6,
maxConnections: 3,
};
// Predefined sessionIDTable names xray-core accepts as a shorthand for a
+45 -1
View File
@@ -1,7 +1,7 @@
/// <reference types="vite/client" />
import { describe, expect, it } from 'vitest';
import { parseGeckoPacketSize } from '@/lib/xray/forms/transport/FinalMaskForm';
import { migrateXmcSettings, parseGeckoPacketSize } from '@/lib/xray/forms/transport/FinalMaskForm';
import { FinalMaskStreamSettingsSchema } from '@/schemas/protocols/stream';
const fixtures = import.meta.glob<unknown>(
@@ -26,6 +26,50 @@ describe('FinalMaskStreamSettingsSchema fixtures', () => {
}
});
describe('migrateXmcSettings', () => {
it('carries legacy usernames into profile stubs and drops the dead key', () => {
const { next, changed } = migrateXmcSettings({
hostname: 'mc.example.com',
usernames: ['Dream', 'Notch'],
password: 'pw',
});
expect(changed).toBe(true);
expect(next.usernames).toBeUndefined();
expect(next.hostname).toBe('mc.example.com');
expect(next.password).toBe('pw');
expect(next.profiles).toEqual([
{ username: 'Dream', uuid: '', texturesValue: '', texturesSignature: '' },
{ username: 'Notch', uuid: '', texturesValue: '', texturesSignature: '' },
]);
});
it('gives a mask with neither key an empty profiles list', () => {
const { next, changed } = migrateXmcSettings({ hostname: '', password: 'pw' });
expect(changed).toBe(true);
expect(next.profiles).toEqual([]);
});
it('leaves an already migrated mask untouched', () => {
const profiles = [
{ username: 'Notch', uuid: '069a79f4-44e9-4726-a5be-fca90e38aaf5', texturesValue: 'dmFsdWU=', texturesSignature: 'c2ln' },
];
const { next, changed } = migrateXmcSettings({ hostname: '', password: 'pw', profiles });
expect(changed).toBe(false);
expect(next.profiles).toEqual(profiles);
});
it('discards blank legacy usernames rather than seeding unfixable stubs', () => {
const { next } = migrateXmcSettings({ usernames: ['Dream', '', ' '], password: 'pw' });
expect(next.profiles).toEqual([
{ username: 'Dream', uuid: '', texturesValue: '', texturesSignature: '' },
]);
});
});
describe('parseGeckoPacketSize', () => {
it('accepts positive ordered packet size ranges', () => {
expect(parseGeckoPacketSize('512-1200')).toEqual({ min: 512, max: 1200 });
@@ -158,8 +158,8 @@ describe('normalizeXhttpForWire stream-one', () => {
expect(XHttpXmuxSchema.parse({}).maxConcurrency).toBe('16-32');
});
it('XMUX_FRESH_DEFAULTS seeds the anti-RKN maxConnections=6 without a competing maxConcurrency', () => {
expect(XMUX_FRESH_DEFAULTS.maxConnections).toBe(6);
it('XMUX_FRESH_DEFAULTS seeds the core maxConnections fallback without a competing maxConcurrency', () => {
expect(XMUX_FRESH_DEFAULTS.maxConnections).toBe(3);
expect(XMUX_FRESH_DEFAULTS.maxConcurrency).toBe('');
const out = normalizeXhttpForWire({
@@ -170,7 +170,7 @@ describe('normalizeXhttpForWire stream-one', () => {
}, 'outbound');
const xmux = out.xmux as Record<string, unknown>;
expect(xmux.maxConnections).toBe(6);
expect(xmux.maxConnections).toBe(3);
expect(xmux.maxConcurrency).toBe('');
});
});