feat(xray): update xray-core to v26.9.9 and follow the udpHop move

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.
This commit is contained in:
Sanaei
2026-09-09 01:53:00 +02:00
parent cfd596a489
commit d0edbcec81
15 changed files with 243 additions and 52 deletions
+26 -4
View File
@@ -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{}{
+33
View File
@@ -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)), ""},
+16 -7
View File
@@ -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 {
+48 -7
View File
@@ -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"))