From d0edbcec81562d4b497428f97adb5325b2b45299 Mon Sep 17 00:00:00 2001 From: Sanaei Date: Wed, 9 Sep 2026 01:53:00 +0200 Subject: [PATCH] feat(xray): update xray-core to v26.9.9 and follow the udpHop move MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/release.yml | 4 +- DockerInit.sh | 2 +- frontend/src/lib/xray/inbound-link.ts | 14 ++++- frontend/src/lib/xray/outbound-link-parser.ts | 20 +++---- .../src/schemas/protocols/stream/finalmask.ts | 9 ++- .../test/__snapshots__/finalmask.test.ts.snap | 19 +++++++ .../golden/fixtures/finalmask/udp-hop.json | 13 +++++ frontend/src/test/inbound-link.test.ts | 31 +++++++++++ .../src/test/outbound-link-parser.test.ts | 36 +++++++----- go.mod | 2 +- go.sum | 4 +- internal/sub/service.go | 30 ++++++++-- internal/sub/service_test.go | 33 +++++++++++ internal/util/link/outbound.go | 23 +++++--- internal/util/link/outbound_test.go | 55 ++++++++++++++++--- 15 files changed, 243 insertions(+), 52 deletions(-) create mode 100644 frontend/src/test/golden/fixtures/finalmask/udp-hop.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dde15ed28..2e44a8db3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -124,7 +124,7 @@ jobs: cd x-ui/bin # 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 fetch ${Xray_URL}Xray-linux-64.zip unzip Xray-linux-64.zip @@ -287,7 +287,7 @@ jobs: cd x-ui\bin # 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" Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath . Remove-Item "Xray-windows-64.zip" diff --git a/DockerInit.sh b/DockerInit.sh index 1d9b1861f..6c7d1795d 100755 --- a/DockerInit.sh +++ b/DockerInit.sh @@ -32,7 +32,7 @@ if [ -z "$MTG_MULTI_VER" ]; then fi mkdir -p 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" rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat mv xray "xray-linux-${FNAME}" diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts index 230c611cd..555d4846e 100644 --- a/frontend/src/lib/xray/inbound-link.ts +++ b/frontend/src/lib/xray/inbound-link.ts @@ -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): 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://@:?#. // 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); } diff --git a/frontend/src/lib/xray/outbound-link-parser.ts b/frontend/src/lib/xray/outbound-link-parser.ts index c171a95fe..1f539591c 100644 --- a/frontend/src/lib/xray/outbound-link-parser.ts +++ b/frontend/src/lib/xray/outbound-link-parser.ts @@ -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 = [ diff --git a/frontend/src/schemas/protocols/stream/finalmask.ts b/frontend/src/schemas/protocols/stream/finalmask.ts index 1bf1168a6..874e9be3d 100644 --- a/frontend/src/schemas/protocols/stream/finalmask.ts +++ b/frontend/src/schemas/protocols/stream/finalmask.ts @@ -19,6 +19,8 @@ export const TcpMaskSchema = z.object({ }); export type TcpMask = z.infer; +// '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; @@ -43,9 +46,9 @@ export type QuicCongestion = z.infer; export const BbrProfileSchema = z.enum(['conservative', 'standard', 'aggressive']); export type BbrProfile = z.infer; -// 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()); diff --git a/frontend/src/test/__snapshots__/finalmask.test.ts.snap b/frontend/src/test/__snapshots__/finalmask.test.ts.snap index a984b2c08..2bddca3e3 100644 --- a/frontend/src/test/__snapshots__/finalmask.test.ts.snap +++ b/frontend/src/test/__snapshots__/finalmask.test.ts.snap @@ -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": [], diff --git a/frontend/src/test/golden/fixtures/finalmask/udp-hop.json b/frontend/src/test/golden/fixtures/finalmask/udp-hop.json new file mode 100644 index 000000000..c27f28eb1 --- /dev/null +++ b/frontend/src/test/golden/fixtures/finalmask/udp-hop.json @@ -0,0 +1,13 @@ +{ + "udp": [ + { + "type": "udphop", + "settings": { + "mode": "intervalremote", + "interval": "5-10", + "remotePorts": "20000-50000", + "remoteIPs": ["203.0.113.0/24"] + } + } + ] +} diff --git a/frontend/src/test/inbound-link.test.ts b/frontend/src/test/inbound-link.test.ts index b39728a7e..ca425215b 100644 --- a/frontend/src/test/inbound-link.test.ts +++ b/frontend/src/test/inbound-link.test.ts @@ -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), version: 2 }, + streamSettings: { + ...(raw.streamSettings as Record), + 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='; diff --git a/frontend/src/test/outbound-link-parser.test.ts b/frontend/src/test/outbound-link-parser.test.ts index e0e7189e1..9991a3294 100644 --- a/frontend/src/test/outbound-link-parser.test.ts +++ b/frontend/src/test/outbound-link-parser.test.ts @@ -443,7 +443,9 @@ describe('parseHysteria2Link', () => { expect((udp[0].settings as Record).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; - const udpHop = quic.udpHop as Record; - expect(udpHop.ports).toBe('20000-50000'); - expect(udpHop.interval).toBe('5-10'); + const udp = finalmask.udp as Array>; + const hop = udp.find((mask) => mask.type === 'udphop'); + expect(hop).toBeDefined(); + const settings = hop!.settings as Record; + expect(settings.remotePorts).toBe('20000-50000'); + expect(settings.interval).toBe('5-10'); + expect(settings.mode).toBe('intervalremote'); + expect((finalmask.quicParams as Record | 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).udpHop as Record< - string, - unknown - >; - expect(udpHop.ports).toBe('30000-40000'); - expect(udpHop.interval).toBe('7-9'); + const udp = finalmask.udp as Array>; + expect(udp).toHaveLength(1); + const settings = udp[0].settings as Record; + expect(settings.remotePorts).toBe('30000-40000'); + expect(settings.interval).toBe('7-9'); }); it('round-trips the salamander packetSize (Gecko) under fm', () => { diff --git a/go.mod b/go.mod index 19532e64e..841653d64 100644 --- a/go.mod +++ b/go.mod @@ -25,7 +25,7 @@ require ( github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/valyala/fasthttp v1.74.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 golang.org/x/crypto v0.56.0 golang.org/x/net v0.58.0 diff --git a/go.sum b/go.sum index e49359816..2375aeb0e 100644 --- a/go.sum +++ b/go.sum @@ -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/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/xray-core v1.260327.1-0.20260908094724-37ceb8b4b65e h1:yQS0pPlOBi8y6bHNXbqx9AYito2pAXgItwKK7Waqaw8= -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 h1:BsUC2sCXcdVCb09SUh1iWku0ci779t4bUIlKUor1ZRI= +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/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= diff --git a/internal/sub/service.go b/internal/sub/service.go index 9a6b698d4..b6d423fd9 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -1368,18 +1368,39 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", "quic")) } -// hysteriaHopPorts returns the configured Hysteria2 UDP port-hopping range -// (finalmask.quicParams.udpHop.ports), or "" when port hopping is off. The -// range is emitted as the v2rayN-compatible `mport` query param; the URL port -// field stays numeric so .NET-Uri-based importers (v2rayN) can parse the link. +// hysteriaHopPorts returns the configured Hysteria2 UDP port-hopping range, or +// "" when port hopping is off. The range is emitted as the v2rayN-compatible +// `mport` query param; the URL port field stays numeric so .NET-Uri-based +// importers (v2rayN) can parse the link. func hysteriaHopPorts(stream map[string]any) string { finalmask, _ := stream["finalmask"].(map[string]any) + if ports := udpHopMaskPorts(finalmask); ports != "" { + return ports + } quicParams, _ := finalmask["quicParams"].(map[string]any) udpHop, _ := quicParams["udpHop"].(map[string]any) ports, _ := udpHop["ports"].(string) 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 // frontend editor, so both link generators emit identical URIs. const ( @@ -2469,6 +2490,7 @@ var validFinalMaskUDPTypes = map[string]struct{}{ "noise": {}, "header-custom": {}, "realm": {}, + "udphop": {}, } var validFinalMaskTCPTypes = map[string]struct{}{ diff --git a/internal/sub/service_test.go b/internal/sub/service_test.go index 5904f1898..a69c6eec4 100644 --- a/internal/sub/service_test.go +++ b/internal/sub/service_test.go @@ -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) { if hasFinalMaskContent(nil) { 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) { withHop := func(ports any) 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 { name string @@ -1144,6 +1169,14 @@ func TestHysteriaHopPorts(t *testing.T) { want string }{ {"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"}, {"empty string", withHop(""), ""}, {"non-string", withHop(float64(443)), ""}, diff --git a/internal/util/link/outbound.go b/internal/util/link/outbound.go index 25dde4e20..f6206646f 100644 --- a/internal/util/link/outbound.go +++ b/internal/util/link/outbound.go @@ -760,21 +760,30 @@ func applyHysteria2Obfs(stream map[string]any, p url.Values) { } // applyHysteria2Hop rebuilds 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 same -// default the panel writes. +// 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; a mask +// already supplied via fm= wins. func applyHysteria2Hop(stream map[string]any, p url.Values) { ports := firstParam(p, "mport") if ports == "" { return } - quicParams := ensureChildMap(ensureChildMap(stream, "finalmask"), "quicParams") - if udpHop, ok := quicParams["udpHop"].(map[string]any); ok { - if existing, _ := udpHop["ports"].(string); existing != "" { + finalmask := ensureChildMap(stream, "finalmask") + masks, _ := finalmask["udp"].([]any) + for _, rawMask := range masks { + mask, _ := rawMask.(map[string]any) + if maskType, _ := mask["type"].(string); maskType == "udphop" { 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 { diff --git a/internal/util/link/outbound_test.go b/internal/util/link/outbound_test.go index eabfc6b2e..acbd0b095 100644 --- a/internal/util/link/outbound_test.go +++ b/internal/util/link/outbound_test.go @@ -148,16 +148,25 @@ func finalmaskUDP(t *testing.T, res *ParseResult) []any { 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) { t.Helper() - stream, _ := res.Outbound["streamSettings"].(map[string]any) - finalmask, _ := stream["finalmask"].(map[string]any) - quicParams, _ := finalmask["quicParams"].(map[string]any) - udpHop, ok := quicParams["udpHop"].(map[string]any) + settings, ok := hopMask(t, res) if !ok { return "", false } - ports, _ := udpHop["ports"].(string) + ports, _ := settings["remotePorts"].(string) return ports, true } @@ -258,11 +267,17 @@ func TestParseHysteria2_Mport(t *testing.T) { {"standard mport", "mport=20000-50000", "20000-50000", true}, {"no mport", "sni=ex.com", "", false}, { - name: "fm udpHop wins over mport", - query: "mport=1-2&fm=" + url.QueryEscape(`{"quicParams":{"udpHop":{"ports":"30000-40000","interval":"7-9"}}}`), + name: "fm udphop mask wins over mport", + query: "mport=1-2&fm=" + url.QueryEscape(`{"udp":[{"type":"udphop","settings":{"mode":"intervalremote","interval":"7-9","remotePorts":"30000-40000"}}]}`), wantPorts: "30000-40000", 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 { 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) { modernUser := base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:secretpass")) legacyBody := base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:secretpass@1.2.3.4:8388"))