Forráskód Böngészése

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
MHSanaei 16 órája
szülő
commit
13d02f01fc

+ 5 - 0
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);

+ 27 - 0
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<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', () => {

+ 6 - 0
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
 }
 

+ 15 - 0
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

+ 34 - 0
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)
+			}
+		})
+	}
+}