From 13d02f01fcdf2f361bb830132d11ca956d2b6123 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Tue, 2 Jun 2026 15:01:18 +0200 Subject: [PATCH] feat(hysteria2): emit UDP port hopping in subscriptions and share links UDP Hop (finalmask.quicParams.udpHop.ports) was configurable but never surfaced in generated configs, so clients kept using the single listening port (#4789). Share links (frontend genHysteriaLink + sub genHysteriaLink) now keep a numeric port in the authority and carry the hop range as the v2rayN-compatible mport query param, so v2rayN and other System.Uri-based importers can parse the link. Clash output sets mihomos native ports field. Closes #4789 --- frontend/src/lib/xray/inbound-link.ts | 5 ++++ frontend/src/test/inbound-link.test.ts | 27 ++++++++++++++++++++ sub/subClashService.go | 6 +++++ sub/subService.go | 15 ++++++++++++ sub/subService_test.go | 34 ++++++++++++++++++++++++++ 5 files changed, 87 insertions(+) diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts index 3e2057cf..943f02f8 100644 --- a/frontend/src/lib/xray/inbound-link.ts +++ b/frontend/src/lib/xray/inbound-link.ts @@ -626,6 +626,11 @@ export function genHysteriaLink(input: GenHysteriaLinkInput): string { applyFinalMaskToParams(stream.finalmask, params); + const hopPorts = stream.finalmask?.quicParams?.udpHop?.ports?.trim() ?? ''; + if (hopPorts.length > 0) { + params.set('mport', hopPorts); + } + const url = new URL(`${scheme}://${clientAuth}@${address}:${port}`); for (const [key, value] of params) url.searchParams.set(key, value); url.hash = encodeURIComponent(remark); diff --git a/frontend/src/test/inbound-link.test.ts b/frontend/src/test/inbound-link.test.ts index a7e36d9d..c75885bf 100644 --- a/frontend/src/test/inbound-link.test.ts +++ b/frontend/src/test/inbound-link.test.ts @@ -131,6 +131,33 @@ describe('genHysteriaLink', () => { expect(link).toMatchSnapshot(); }); } + + it('emits the UDP hop range as the v2rayN-compatible mport param', () => { + const [, raw] = fixtures[0]; + const withHop = { + ...raw, + settings: { ...(raw.settings as Record), version: 2 }, + streamSettings: { + ...(raw.streamSettings as Record), + finalmask: { quicParams: { udpHop: { ports: '20000-50000', interval: '5-10' } } }, + }, + }; + 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-test', + clientAuth: client.auth, + }); + + expect(link.startsWith('hysteria2://')).toBe(true); + expect(link).toContain(`@example.test:${typed.port}`); + expect(link).toContain('mport=20000-50000'); + expect(link.endsWith('#hop-test')).toBe(true); + }); }); describe('genWireguardLink + genWireguardConfig', () => { diff --git a/sub/subClashService.go b/sub/subClashService.go index bf06bbaf..1dc61d67 100644 --- a/sub/subClashService.go +++ b/sub/subClashService.go @@ -325,6 +325,12 @@ func (s *SubClashService) buildHysteriaProxy(inbound *model.Inbound, client mode } } + // UDP port hopping. mihomo reads the range from a dedicated `ports` + // field (the base `port` stays as the redirect target). + if hopPorts := hysteriaHopPorts(rawStream); hopPorts != "" { + proxy["ports"] = hopPorts + } + return proxy } diff --git a/sub/subService.go b/sub/subService.go index 6bcaa81e..09f45609 100644 --- a/sub/subService.go +++ b/sub/subService.go @@ -670,10 +670,25 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin // No external proxy configured — use the inbound's resolved address so // node-managed inbounds get the node's host instead of the central panel's. + if hopPorts := hysteriaHopPorts(stream); hopPorts != "" { + params["mport"] = hopPorts + } link := fmt.Sprintf("%s://%s@%s:%d", protocol, auth, s.resolveInboundAddress(inbound), inbound.Port) return buildLinkWithParams(link, params, s.genRemark(inbound, email, "")) } +// 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. +func hysteriaHopPorts(stream map[string]any) string { + finalmask, _ := stream["finalmask"].(map[string]any) + quicParams, _ := finalmask["quicParams"].(map[string]any) + udpHop, _ := quicParams["udpHop"].(map[string]any) + ports, _ := udpHop["ports"].(string) + return strings.TrimSpace(ports) +} + // loadNodes refreshes nodesByID from the DB. Called once per request so // the per-inbound resolveInboundAddress lookups are pure map reads. // We filter to address != ” so a half-configured node row doesn't diff --git a/sub/subService_test.go b/sub/subService_test.go index 2d3e2a04..ea983392 100644 --- a/sub/subService_test.go +++ b/sub/subService_test.go @@ -779,3 +779,37 @@ func TestHasFinalMaskContent(t *testing.T) { t.Fatal("non-empty map should count as content") } } + +func TestHysteriaHopPorts(t *testing.T) { + withHop := func(ports any) map[string]any { + return map[string]any{ + "finalmask": map[string]any{ + "quicParams": map[string]any{ + "udpHop": map[string]any{"ports": ports, "interval": "5-10"}, + }, + }, + } + } + + cases := []struct { + name string + stream map[string]any + want string + }{ + {"range", withHop("20000-50000"), "20000-50000"}, + {"trimmed", withHop(" 443,20000-50000 "), "443,20000-50000"}, + {"empty string", withHop(""), ""}, + {"non-string", withHop(float64(443)), ""}, + {"no udpHop", map[string]any{"finalmask": map[string]any{"quicParams": map[string]any{}}}, ""}, + {"no finalmask", map[string]any{}, ""}, + {"nil stream", nil, ""}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := hysteriaHopPorts(tc.stream); got != tc.want { + t.Fatalf("hysteriaHopPorts() = %q, want %q", got, tc.want) + } + }) + } +}