feat(xray): update xray-core to v26.9.9 and follow the udpHop move

Bump xtls/xray-core to 52a412d9e2f5 (v26.9.9) and the three binary pins in
DockerInit.sh and release.yml in lockstep.

Upstream moved UDP port hopping out of finalmask.quicParams.udpHop and into a
standalone "udphop" UDP mask with a different shape (mode / interval /
remotePorts / remoteIPs). The old key is gone from QuicParams, and since the
config loader ignores unknown fields it is now silently dropped rather than
rejected — port hopping just stops.

The panel adapts where that key was live:

- Both link importers rebuilt quicParams.udpHop from the standard mport param,
  so an imported hysteria2 link produced an outbound that no longer hops. They
  now emit a udphop mask in intervalremote mode, which is what the old key did.
  The mode is required: UDPHop.Build() rejects an empty or unknown one.
- validFinalMaskUDPTypes and UdpMaskTypeSchema learn "udphop", otherwise the Go
  link generator strips the mask from every link and sub, and Zod strips it on
  the next form round trip.
- mport generation (Go and frontend) reads the mask first and keeps reading the
  legacy key, so inbounds stored before the upgrade still advertise their range.

On an inbound the old key was always inert — only hysteria's dialer consumed
it — so nothing regresses server-side and no migration is needed. udphop stays
out of the mask dropdown on purpose: it is client-only in core, which refuses
to wrap a server socket, and that form is shared with the inbound editor.
This commit is contained in:
Sanaei
2026-09-09 01:53:00 +02:00
parent cfd596a489
commit d0edbcec81
15 changed files with 243 additions and 52 deletions
+13 -1
View File
@@ -751,6 +751,18 @@ function hysteriaPinHex(pin: string): string {
}
}
// Hysteria2 hop range advertised as `mport`. xray-core 26.9.9 moved hopping
// from finalmask.quicParams.udpHop to a 'udphop' UDP mask; inbounds stored
// before the upgrade still carry the old key.
function udpHopPorts(stream: NonNullable<Inbound['streamSettings']>): string {
for (const mask of stream.finalmask?.udp ?? []) {
if (mask.type !== 'udphop') continue;
const ports = mask.settings?.remotePorts;
if (typeof ports === 'string' && ports.trim().length > 0) return ports.trim();
}
return stream.finalmask?.quicParams?.udpHop?.ports?.trim() ?? '';
}
// Hysteria share link: hysteria2://<auth>@<host>:<port>?<query>#<remark>.
// The scheme is always hysteria2 — xray-core builds version 2 only, so the
// settings schema pins it there and the subscription server emits the same
@@ -817,7 +829,7 @@ export function genHysteriaLink(input: GenHysteriaLinkInput): string {
}
}
const hopPorts = stream.finalmask?.quicParams?.udpHop?.ports?.trim() ?? '';
const hopPorts = udpHopPorts(stream);
if (hopPorts.length > 0) {
params.set('mport', hopPorts);
}
+9 -11
View File
@@ -311,21 +311,19 @@ function applyHysteria2Obfs(stream: Raw, params: URLSearchParams): void {
finalmask.udp = [...udp, { type: 'salamander', settings }];
}
// Rebuild the UDP port-hopping range from the standard mport param, which the
// generator emits as finalmask.quicParams.udpHop.ports. A range already supplied
// via fm= wins; the client-side interval falls back to the panel's default.
// Rebuild the UDP port-hopping range from the standard mport param. xray-core
// 26.9.9 replaced finalmask.quicParams.udpHop with a 'udphop' UDP mask, whose
// intervalremote mode is what the old key used to do; an fm= mask wins.
function applyHysteria2Hop(stream: Raw, params: URLSearchParams): void {
const ports = firstParam(params, 'mport');
if (!ports) return;
const finalmask = ensureFinalMask(stream);
const quicParams = (
finalmask.quicParams && typeof finalmask.quicParams === 'object'
? finalmask.quicParams
: (finalmask.quicParams = {})
) as Raw;
const existingHop = quicParams.udpHop as Raw | undefined;
if (existingHop && typeof existingHop.ports === 'string' && existingHop.ports.length > 0) return;
quicParams.udpHop = { ports, interval: '5-10' };
const udp = Array.isArray(finalmask.udp) ? (finalmask.udp as Raw[]) : [];
if (udp.some((mask) => (mask as Raw | undefined)?.type === 'udphop')) return;
finalmask.udp = [
...udp,
{ type: 'udphop', settings: { mode: 'intervalremote', interval: '5-10', remotePorts: ports } },
];
}
const QUIC_PARAMS_NUMERIC_KEYS = [
@@ -19,6 +19,8 @@ export const TcpMaskSchema = z.object({
});
export type TcpMask = z.infer<typeof TcpMaskSchema>;
// 'udphop' is client-only in xray-core (it refuses to wrap a server socket),
// so it round-trips here but is deliberately absent from the mask dropdown.
export const UdpMaskTypeSchema = z.enum([
'salamander',
'mkcp-legacy',
@@ -28,6 +30,7 @@ export const UdpMaskTypeSchema = z.enum([
'noise',
'sudoku',
'realm',
'udphop',
]);
export type UdpMaskType = z.infer<typeof UdpMaskTypeSchema>;
@@ -43,9 +46,9 @@ export type QuicCongestion = z.infer<typeof QuicCongestionSchema>;
export const BbrProfileSchema = z.enum(['conservative', 'standard', 'aggressive']);
export type BbrProfile = z.infer<typeof BbrProfileSchema>;
// udpHop randomizes the QUIC port between a range every `interval` seconds
// to dodge port-based blocking. Both fields are dash-range strings on the
// wire (e.g. '20000-50000', '5-10'). preprocess coerces legacy DB rows
// udpHop declares the hop range advertised to clients as `mport`. xray-core
// 26.9.9 moved actual hopping to the 'udphop' UDP mask and ignores this key,
// which was always inert server-side. preprocess coerces legacy DB rows
// where interval was stored as a number (UI bug — see B19 in commit history).
const StringRangeSchema = z.preprocess((v) => (typeof v === 'number' ? String(v) : v), z.string());
@@ -201,6 +201,25 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses tcp-mask byte-stably 1`
}
`;
exports[`FinalMaskStreamSettingsSchema fixtures > parses udp-hop byte-stably 1`] = `
{
"tcp": [],
"udp": [
{
"settings": {
"interval": "5-10",
"mode": "intervalremote",
"remoteIPs": [
"203.0.113.0/24",
],
"remotePorts": "20000-50000",
},
"type": "udphop",
},
],
}
`;
exports[`FinalMaskStreamSettingsSchema fixtures > parses udp-mask byte-stably 1`] = `
{
"tcp": [],
@@ -0,0 +1,13 @@
{
"udp": [
{
"type": "udphop",
"settings": {
"mode": "intervalremote",
"interval": "5-10",
"remotePorts": "20000-50000",
"remoteIPs": ["203.0.113.0/24"]
}
}
]
}
+31
View File
@@ -233,6 +233,37 @@ describe('genHysteriaLink', () => {
expect(link.endsWith('#hop-test')).toBe(true);
});
it('emits mport from the udphop mask xray-core 26.9.9 moved hopping to', () => {
const [, raw] = fixtures[0];
const withHop = {
...raw,
settings: { ...(raw.settings as Record<string, unknown>), version: 2 },
streamSettings: {
...(raw.streamSettings as Record<string, unknown>),
finalmask: {
udp: [
{
type: 'udphop',
settings: { mode: 'intervalremote', interval: '5-10', remotePorts: '30000-40000' },
},
],
},
},
};
const typed = InboundSchema.parse(withHop);
const client = (raw.settings as { clients: Array<{ auth: string }> }).clients[0];
const link = genHysteriaLink({
inbound: typed,
address: 'example.test',
port: typed.port,
remark: 'hop-mask',
clientAuth: client.auth,
});
expect(link).toContain('mport=30000-40000');
});
it('normalizes pinSHA256 to hex for base64, raw-hex and colon-hex pins (issue #4818)', () => {
const [, raw] = fixtures[0];
const base64Pin = 'yEfdI5XQl4wHgLggHEsomosoFZfUfCdfLXfT+W2N6cQ=';
+23 -13
View File
@@ -443,7 +443,9 @@ describe('parseHysteria2Link', () => {
expect((udp[0].settings as Record<string, unknown>).password).toBe('fromobfs');
});
it('reconstructs udpHop from the standard mport param', () => {
// xray-core 26.9.9 ignores quicParams.udpHop; hopping is a 'udphop' UDP mask
// and its mode must be one the core's UDPHop.Build() accepts.
it('reconstructs a udphop mask from the standard mport param', () => {
const out = parseHysteria2Link(
'hysteria2://auth@srv:443?security=tls&mport=20000-50000#hy2-mport',
);
@@ -451,16 +453,25 @@ describe('parseHysteria2Link', () => {
string,
unknown
>;
const quic = finalmask.quicParams as Record<string, unknown>;
const udpHop = quic.udpHop as Record<string, unknown>;
expect(udpHop.ports).toBe('20000-50000');
expect(udpHop.interval).toBe('5-10');
const udp = finalmask.udp as Array<Record<string, unknown>>;
const hop = udp.find((mask) => mask.type === 'udphop');
expect(hop).toBeDefined();
const settings = hop!.settings as Record<string, unknown>;
expect(settings.remotePorts).toBe('20000-50000');
expect(settings.interval).toBe('5-10');
expect(settings.mode).toBe('intervalremote');
expect((finalmask.quicParams as Record<string, unknown> | undefined)?.udpHop).toBeUndefined();
});
it('lets an fm= udpHop win over mport', () => {
it('lets an fm= udphop mask win over mport', () => {
const fm = encodeURIComponent(
JSON.stringify({
quicParams: { udpHop: { ports: '30000-40000', interval: '7-9' } },
udp: [
{
type: 'udphop',
settings: { mode: 'intervalremote', interval: '7-9', remotePorts: '30000-40000' },
},
],
}),
);
const link = `hysteria2://auth@srv:443?security=tls&mport=1-2&fm=${fm}#hy2-mport-fm`;
@@ -469,12 +480,11 @@ describe('parseHysteria2Link', () => {
string,
unknown
>;
const udpHop = (finalmask.quicParams as Record<string, unknown>).udpHop as Record<
string,
unknown
>;
expect(udpHop.ports).toBe('30000-40000');
expect(udpHop.interval).toBe('7-9');
const udp = finalmask.udp as Array<Record<string, unknown>>;
expect(udp).toHaveLength(1);
const settings = udp[0].settings as Record<string, unknown>;
expect(settings.remotePorts).toBe('30000-40000');
expect(settings.interval).toBe('7-9');
});
it('round-trips the salamander packetSize (Gecko) under fm', () => {