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
+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"))