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
This commit is contained in:
MHSanaei
2026-06-02 15:01:18 +02:00
parent 2f12b34635
commit 13d02f01fc
5 changed files with 87 additions and 0 deletions

View File

@@ -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);

View File

@@ -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<string, unknown>), version: 2 },
streamSettings: {
...(raw.streamSettings as Record<string, unknown>),
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', () => {

View File

@@ -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
}

View File

@@ -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

View File

@@ -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)
}
})
}
}