mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-03 19:09:36 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user