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
+2 -2
View File
@@ -124,7 +124,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.8/" Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.9.9/"
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
@@ -287,7 +287,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.8/" $Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.9.9/"
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
@@ -32,7 +32,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.8/Xray-linux-${ARCH}.zip" curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.9.9/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}"
+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>. // Hysteria share link: hysteria2://<auth>@<host>:<port>?<query>#<remark>.
// The scheme is always hysteria2 — xray-core builds version 2 only, so the // 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 // 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) { if (hopPorts.length > 0) {
params.set('mport', hopPorts); 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 }]; finalmask.udp = [...udp, { type: 'salamander', settings }];
} }
// Rebuild the UDP port-hopping range from the standard mport param, which the // Rebuild the UDP port-hopping range from the standard mport param. xray-core
// generator emits as finalmask.quicParams.udpHop.ports. A range already supplied // 26.9.9 replaced finalmask.quicParams.udpHop with a 'udphop' UDP mask, whose
// via fm= wins; the client-side interval falls back to the panel's default. // intervalremote mode is what the old key used to do; an fm= mask wins.
function applyHysteria2Hop(stream: Raw, params: URLSearchParams): void { function applyHysteria2Hop(stream: Raw, params: URLSearchParams): void {
const ports = firstParam(params, 'mport'); const ports = firstParam(params, 'mport');
if (!ports) return; if (!ports) return;
const finalmask = ensureFinalMask(stream); const finalmask = ensureFinalMask(stream);
const quicParams = ( const udp = Array.isArray(finalmask.udp) ? (finalmask.udp as Raw[]) : [];
finalmask.quicParams && typeof finalmask.quicParams === 'object' if (udp.some((mask) => (mask as Raw | undefined)?.type === 'udphop')) return;
? finalmask.quicParams finalmask.udp = [
: (finalmask.quicParams = {}) ...udp,
) as Raw; { type: 'udphop', settings: { mode: 'intervalremote', interval: '5-10', remotePorts: ports } },
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 QUIC_PARAMS_NUMERIC_KEYS = [ const QUIC_PARAMS_NUMERIC_KEYS = [
@@ -19,6 +19,8 @@ export const TcpMaskSchema = z.object({
}); });
export type TcpMask = z.infer<typeof TcpMaskSchema>; 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([ export const UdpMaskTypeSchema = z.enum([
'salamander', 'salamander',
'mkcp-legacy', 'mkcp-legacy',
@@ -28,6 +30,7 @@ export const UdpMaskTypeSchema = z.enum([
'noise', 'noise',
'sudoku', 'sudoku',
'realm', 'realm',
'udphop',
]); ]);
export type UdpMaskType = z.infer<typeof UdpMaskTypeSchema>; 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 const BbrProfileSchema = z.enum(['conservative', 'standard', 'aggressive']);
export type BbrProfile = z.infer<typeof BbrProfileSchema>; export type BbrProfile = z.infer<typeof BbrProfileSchema>;
// udpHop randomizes the QUIC port between a range every `interval` seconds // udpHop declares the hop range advertised to clients as `mport`. xray-core
// to dodge port-based blocking. Both fields are dash-range strings on the // 26.9.9 moved actual hopping to the 'udphop' UDP mask and ignores this key,
// wire (e.g. '20000-50000', '5-10'). preprocess coerces legacy DB rows // 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). // 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()); 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`] = ` exports[`FinalMaskStreamSettingsSchema fixtures > parses udp-mask byte-stably 1`] = `
{ {
"tcp": [], "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); 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)', () => { it('normalizes pinSHA256 to hex for base64, raw-hex and colon-hex pins (issue #4818)', () => {
const [, raw] = fixtures[0]; const [, raw] = fixtures[0];
const base64Pin = 'yEfdI5XQl4wHgLggHEsomosoFZfUfCdfLXfT+W2N6cQ='; 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'); 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( const out = parseHysteria2Link(
'hysteria2://auth@srv:443?security=tls&mport=20000-50000#hy2-mport', 'hysteria2://auth@srv:443?security=tls&mport=20000-50000#hy2-mport',
); );
@@ -451,16 +453,25 @@ describe('parseHysteria2Link', () => {
string, string,
unknown unknown
>; >;
const quic = finalmask.quicParams as Record<string, unknown>; const udp = finalmask.udp as Array<Record<string, unknown>>;
const udpHop = quic.udpHop as Record<string, unknown>; const hop = udp.find((mask) => mask.type === 'udphop');
expect(udpHop.ports).toBe('20000-50000'); expect(hop).toBeDefined();
expect(udpHop.interval).toBe('5-10'); 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( const fm = encodeURIComponent(
JSON.stringify({ 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`; const link = `hysteria2://auth@srv:443?security=tls&mport=1-2&fm=${fm}#hy2-mport-fm`;
@@ -469,12 +480,11 @@ describe('parseHysteria2Link', () => {
string, string,
unknown unknown
>; >;
const udpHop = (finalmask.quicParams as Record<string, unknown>).udpHop as Record< const udp = finalmask.udp as Array<Record<string, unknown>>;
string, expect(udp).toHaveLength(1);
unknown const settings = udp[0].settings as Record<string, unknown>;
>; expect(settings.remotePorts).toBe('30000-40000');
expect(udpHop.ports).toBe('30000-40000'); expect(settings.interval).toBe('7-9');
expect(udpHop.interval).toBe('7-9');
}); });
it('round-trips the salamander packetSize (Gecko) under fm', () => { it('round-trips the salamander packetSize (Gecko) under fm', () => {
+1 -1
View File
@@ -25,7 +25,7 @@ require (
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/valyala/fasthttp v1.74.0 github.com/valyala/fasthttp v1.74.0
github.com/xlzd/gotp v0.1.0 github.com/xlzd/gotp v0.1.0
github.com/xtls/xray-core v1.260327.1-0.20260908094724-37ceb8b4b65e github.com/xtls/xray-core v1.260327.1-0.20260908222543-52a412d9e2f5
go.uber.org/atomic v1.11.0 go.uber.org/atomic v1.11.0
golang.org/x/crypto v0.56.0 golang.org/x/crypto v0.56.0
golang.org/x/net v0.58.0 golang.org/x/net v0.58.0
+2 -2
View File
@@ -231,8 +231,8 @@ github.com/xlzd/gotp v0.1.0 h1:37blvlKCh38s+fkem+fFh7sMnceltoIEBYTVXyoa5Po=
github.com/xlzd/gotp v0.1.0/go.mod h1:ndLJ3JKzi3xLmUProq4LLxCuECL93dG9WASNLpHz8qg= github.com/xlzd/gotp v0.1.0/go.mod h1:ndLJ3JKzi3xLmUProq4LLxCuECL93dG9WASNLpHz8qg=
github.com/xtls/reality v0.0.0-20260908062103-8cdf7bf9c7f0 h1:rb+fKQFhz+5I2PPuQsNYxI5mUU840XWYtRF0ZBjvkws= github.com/xtls/reality v0.0.0-20260908062103-8cdf7bf9c7f0 h1:rb+fKQFhz+5I2PPuQsNYxI5mUU840XWYtRF0ZBjvkws=
github.com/xtls/reality v0.0.0-20260908062103-8cdf7bf9c7f0/go.mod h1:DsJblcWDGt76+FVqBVwbwRhxyyNJsGV48gJLch0OOWI= github.com/xtls/reality v0.0.0-20260908062103-8cdf7bf9c7f0/go.mod h1:DsJblcWDGt76+FVqBVwbwRhxyyNJsGV48gJLch0OOWI=
github.com/xtls/xray-core v1.260327.1-0.20260908094724-37ceb8b4b65e h1:yQS0pPlOBi8y6bHNXbqx9AYito2pAXgItwKK7Waqaw8= github.com/xtls/xray-core v1.260327.1-0.20260908222543-52a412d9e2f5 h1:BsUC2sCXcdVCb09SUh1iWku0ci779t4bUIlKUor1ZRI=
github.com/xtls/xray-core v1.260327.1-0.20260908094724-37ceb8b4b65e/go.mod h1:G9OqFEPNkwNoxywvEkdBncyfeytCzC31CBazgy3d9ic= github.com/xtls/xray-core v1.260327.1-0.20260908222543-52a412d9e2f5/go.mod h1:obbr2WDmr/cpQ/YLe1k0HTULnFXCO0rTWIeSrHoFk3o=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
+26 -4
View File
@@ -1368,18 +1368,39 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", "quic")) return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", "quic"))
} }
// hysteriaHopPorts returns the configured Hysteria2 UDP port-hopping range // hysteriaHopPorts returns the configured Hysteria2 UDP port-hopping range, or
// (finalmask.quicParams.udpHop.ports), or "" when port hopping is off. The // "" when port hopping is off. The range is emitted as the v2rayN-compatible
// range is emitted as the v2rayN-compatible `mport` query param; the URL port // `mport` query param; the URL port field stays numeric so .NET-Uri-based
// field stays numeric so .NET-Uri-based importers (v2rayN) can parse the link. // importers (v2rayN) can parse the link.
func hysteriaHopPorts(stream map[string]any) string { func hysteriaHopPorts(stream map[string]any) string {
finalmask, _ := stream["finalmask"].(map[string]any) finalmask, _ := stream["finalmask"].(map[string]any)
if ports := udpHopMaskPorts(finalmask); ports != "" {
return ports
}
quicParams, _ := finalmask["quicParams"].(map[string]any) quicParams, _ := finalmask["quicParams"].(map[string]any)
udpHop, _ := quicParams["udpHop"].(map[string]any) udpHop, _ := quicParams["udpHop"].(map[string]any)
ports, _ := udpHop["ports"].(string) ports, _ := udpHop["ports"].(string)
return strings.TrimSpace(ports) return strings.TrimSpace(ports)
} }
// udpHopMaskPorts reads remotePorts off the first "udphop" UDP mask. xray-core
// 26.9.9 moved hopping here from finalmask.quicParams.udpHop, which it now ignores.
func udpHopMaskPorts(finalmask map[string]any) string {
masks, _ := finalmask["udp"].([]any)
for _, rawMask := range masks {
mask, _ := rawMask.(map[string]any)
if maskType, _ := mask["type"].(string); maskType != "udphop" {
continue
}
settings, _ := mask["settings"].(map[string]any)
ports, _ := settings["remotePorts"].(string)
if ports = strings.TrimSpace(ports); ports != "" {
return ports
}
}
return ""
}
// gecko packetSize bounds mirror xray-core's salamander buffer cap and the // gecko packetSize bounds mirror xray-core's salamander buffer cap and the
// frontend editor, so both link generators emit identical URIs. // frontend editor, so both link generators emit identical URIs.
const ( const (
@@ -2469,6 +2490,7 @@ var validFinalMaskUDPTypes = map[string]struct{}{
"noise": {}, "noise": {},
"header-custom": {}, "header-custom": {},
"realm": {}, "realm": {},
"udphop": {},
} }
var validFinalMaskTCPTypes = map[string]struct{}{ var validFinalMaskTCPTypes = map[string]struct{}{
+33
View File
@@ -1081,6 +1081,19 @@ func TestMarshalFinalMask_KeepsXmcTcpMask(t *testing.T) {
} }
} }
func TestMarshalFinalMask_KeepsUdpHopMask(t *testing.T) {
fm := map[string]any{
"udp": []any{udpHopMask("20000-50000")},
}
out, ok := marshalFinalMask(fm)
if !ok {
t.Fatal("expected ok=true for a udphop udp mask")
}
if !strings.Contains(out, "udphop") || !strings.Contains(out, "20000-50000") {
t.Fatalf("marshaled finalmask dropped the udphop mask: %s", out)
}
}
func TestHasFinalMaskContent(t *testing.T) { func TestHasFinalMaskContent(t *testing.T) {
if hasFinalMaskContent(nil) { if hasFinalMaskContent(nil) {
t.Fatal("nil should not count as content") t.Fatal("nil should not count as content")
@@ -1127,6 +1140,13 @@ func TestHysteriaPinHex(t *testing.T) {
} }
} }
func udpHopMask(ports string) map[string]any {
return map[string]any{
"type": "udphop",
"settings": map[string]any{"mode": "intervalremote", "interval": "5-10", "remotePorts": ports},
}
}
func TestHysteriaHopPorts(t *testing.T) { func TestHysteriaHopPorts(t *testing.T) {
withHop := func(ports any) map[string]any { withHop := func(ports any) map[string]any {
return map[string]any{ return map[string]any{
@@ -1137,6 +1157,11 @@ func TestHysteriaHopPorts(t *testing.T) {
}, },
} }
} }
withHopMask := func(ports string) map[string]any {
return map[string]any{
"finalmask": map[string]any{"udp": []any{udpHopMask(ports)}},
}
}
cases := []struct { cases := []struct {
name string name string
@@ -1144,6 +1169,14 @@ func TestHysteriaHopPorts(t *testing.T) {
want string want string
}{ }{
{"range", withHop("20000-50000"), "20000-50000"}, {"range", withHop("20000-50000"), "20000-50000"},
{"udphop mask", withHopMask("20000-50000"), "20000-50000"},
{"udphop mask wins over legacy key", map[string]any{
"finalmask": map[string]any{
"udp": []any{udpHopMask("30000-40000")},
"quicParams": map[string]any{"udpHop": map[string]any{"ports": "20000-50000"}},
},
}, "30000-40000"},
{"udphop mask without remotePorts", withHopMask(""), ""},
{"trimmed", withHop(" 443,20000-50000 "), "443,20000-50000"}, {"trimmed", withHop(" 443,20000-50000 "), "443,20000-50000"},
{"empty string", withHop(""), ""}, {"empty string", withHop(""), ""},
{"non-string", withHop(float64(443)), ""}, {"non-string", withHop(float64(443)), ""},
+16 -7
View File
@@ -760,21 +760,30 @@ func applyHysteria2Obfs(stream map[string]any, p url.Values) {
} }
// applyHysteria2Hop rebuilds the UDP port-hopping range from the standard mport // applyHysteria2Hop rebuilds the UDP port-hopping range from the standard mport
// param, which the generator emits as finalmask.quicParams.udpHop.ports. A range // param. xray-core 26.9.9 replaced finalmask.quicParams.udpHop with a "udphop"
// already supplied via fm= wins; the client-side interval falls back to the same // UDP mask, whose intervalremote mode is what the old key used to do; a mask
// default the panel writes. // already supplied via fm= wins.
func applyHysteria2Hop(stream map[string]any, p url.Values) { func applyHysteria2Hop(stream map[string]any, p url.Values) {
ports := firstParam(p, "mport") ports := firstParam(p, "mport")
if ports == "" { if ports == "" {
return return
} }
quicParams := ensureChildMap(ensureChildMap(stream, "finalmask"), "quicParams") finalmask := ensureChildMap(stream, "finalmask")
if udpHop, ok := quicParams["udpHop"].(map[string]any); ok { masks, _ := finalmask["udp"].([]any)
if existing, _ := udpHop["ports"].(string); existing != "" { for _, rawMask := range masks {
mask, _ := rawMask.(map[string]any)
if maskType, _ := mask["type"].(string); maskType == "udphop" {
return return
} }
} }
quicParams["udpHop"] = map[string]any{"ports": ports, "interval": "5-10"} finalmask["udp"] = append(masks, map[string]any{
"type": "udphop",
"settings": map[string]any{
"mode": "intervalremote",
"interval": "5-10",
"remotePorts": ports,
},
})
} }
func ensureChildMap(parent map[string]any, key string) map[string]any { func ensureChildMap(parent map[string]any, key string) map[string]any {
+48 -7
View File
@@ -148,16 +148,25 @@ func finalmaskUDP(t *testing.T, res *ParseResult) []any {
return udp return udp
} }
func hopMask(t *testing.T, res *ParseResult) (map[string]any, bool) {
t.Helper()
for _, rawMask := range finalmaskUDP(t, res) {
mask, _ := rawMask.(map[string]any)
if maskType, _ := mask["type"].(string); maskType == "udphop" {
settings, _ := mask["settings"].(map[string]any)
return settings, true
}
}
return nil, false
}
func hopPorts(t *testing.T, res *ParseResult) (string, bool) { func hopPorts(t *testing.T, res *ParseResult) (string, bool) {
t.Helper() t.Helper()
stream, _ := res.Outbound["streamSettings"].(map[string]any) settings, ok := hopMask(t, res)
finalmask, _ := stream["finalmask"].(map[string]any)
quicParams, _ := finalmask["quicParams"].(map[string]any)
udpHop, ok := quicParams["udpHop"].(map[string]any)
if !ok { if !ok {
return "", false return "", false
} }
ports, _ := udpHop["ports"].(string) ports, _ := settings["remotePorts"].(string)
return ports, true return ports, true
} }
@@ -258,11 +267,17 @@ func TestParseHysteria2_Mport(t *testing.T) {
{"standard mport", "mport=20000-50000", "20000-50000", true}, {"standard mport", "mport=20000-50000", "20000-50000", true},
{"no mport", "sni=ex.com", "", false}, {"no mport", "sni=ex.com", "", false},
{ {
name: "fm udpHop wins over mport", name: "fm udphop mask wins over mport",
query: "mport=1-2&fm=" + url.QueryEscape(`{"quicParams":{"udpHop":{"ports":"30000-40000","interval":"7-9"}}}`), query: "mport=1-2&fm=" + url.QueryEscape(`{"udp":[{"type":"udphop","settings":{"mode":"intervalremote","interval":"7-9","remotePorts":"30000-40000"}}]}`),
wantPorts: "30000-40000", wantPorts: "30000-40000",
wantHop: true, wantHop: true,
}, },
{
name: "legacy fm quicParams.udpHop no longer suppresses mport",
query: "mport=1-2&fm=" + url.QueryEscape(`{"quicParams":{"udpHop":{"ports":"30000-40000","interval":"7-9"}}}`),
wantPorts: "1-2",
wantHop: true,
},
} }
for _, c := range cases { for _, c := range cases {
t.Run(c.name, func(t *testing.T) { t.Run(c.name, func(t *testing.T) {
@@ -281,6 +296,32 @@ func TestParseHysteria2_Mport(t *testing.T) {
} }
} }
// xray-core 26.9.9 rejects a udphop mask whose mode is empty or unknown, so
// the mport importer must emit a mode the core's UDPHop.Build() accepts.
func TestParseHysteria2_MportEmitsCoreAcceptedMask(t *testing.T) {
res, err := ParseLink("hysteria2://auth@1.2.3.4:443?security=tls&mport=20000-50000#node")
if err != nil {
t.Fatalf("parse hysteria2: %v", err)
}
settings, ok := hopMask(t, res)
if !ok {
t.Fatalf("no udphop mask (stream: %v)", res.Outbound["streamSettings"])
}
if got, _ := settings["mode"].(string); got != "intervalremote" {
t.Errorf("mode = %q, want %q", got, "intervalremote")
}
if got, _ := settings["interval"].(string); got != "5-10" {
t.Errorf("interval = %q, want %q", got, "5-10")
}
stream, _ := res.Outbound["streamSettings"].(map[string]any)
finalmask, _ := stream["finalmask"].(map[string]any)
if quicParams, ok := finalmask["quicParams"].(map[string]any); ok {
if _, dead := quicParams["udpHop"]; dead {
t.Error("importer still writes the quicParams.udpHop key the core ignores")
}
}
}
func TestParseShadowsocks(t *testing.T) { func TestParseShadowsocks(t *testing.T) {
modernUser := base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:secretpass")) modernUser := base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:secretpass"))
legacyBody := base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:secretpass@1.2.3.4:8388")) legacyBody := base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:secretpass@1.2.3.4:8388"))