fix(sub): keep the udphop mask on every JSON-subscription endpoint

UpgradeLegacyUDPHop deleted udpHop from the quicParams map it was handed
in place. The JSON subscription runs it once per endpoint on a finalmask
that mergeFinalMask (host masks) or withLegacyFragmentRanges cloned only
shallowly, so the first endpoint emptied the inbound's shared quicParams
and every later endpoint got {"quicParams":{}} and no udphop mask: a
hysteria inbound with port hopping and two hosts hopped on the first
host's config only.

The upgrade now replaces quicParams and udp on the finalmask it is given
instead of writing into values a merged finalmask shares, as the
frontend's upgradeLegacyUdpHop already does.
This commit is contained in:
MHSanaei
2026-10-11 16:35:16 +02:00
parent c7d89ceac2
commit 27e45f3039
2 changed files with 55 additions and 6 deletions
+42
View File
@@ -114,3 +114,45 @@ func TestSubJsonServiceTurnsInboundHopIntoClientUDPHopMask(t *testing.T) {
t.Fatalf("client finalmask\n got: %s\nwant: %s", got, want)
}
}
// Every endpoint's config starts from the same parsed inbound finalmask; a host whose own
// masks are merged in first must not use up the hop range the endpoints after it need.
func TestSubJsonServiceKeepsUDPHopMaskOnEveryEndpoint(t *testing.T) {
inbound := &model.Inbound{
Listen: "203.0.113.10", Port: 443, Protocol: model.Hysteria, Remark: "hy",
Settings: `{"version":2,"clients":[{"email":"ivy@example.test","auth":"a"}]}`,
StreamSettings: `{"network":"hysteria","security":"tls",
"hysteriaSettings":{"version":2},
"tlsSettings":{"serverName":"hy.example.test","alpn":["h3"]},
"finalmask":{"quicParams":{"udpHop":{"ports":"20000-30000","interval":"5-10"}}},
"externalProxy":[
{"forceTls":"same","dest":"a.example.test","port":443,"remark":"a",
"finalMask":"{\"udp\":[{\"type\":\"salamander\",\"settings\":{\"password\":\"p\"}}]}"},
{"forceTls":"same","dest":"b.example.test","port":443,"remark":"b"}]}`,
}
client := model.Client{Email: "ivy@example.test", Auth: "a"}
configs := NewSubJsonService("", "", "", "", nil).getConfig(&SubService{address: "sub.example.com"}, inbound, client, "sub.example.com")
if len(configs) != 2 {
t.Fatalf("got %d configs, want one per endpoint", len(configs))
}
const hop = `{"settings":{"interval":"5-10","mode":"intervalRemote","remotePorts":"20000-30000"},"type":"udphop"}`
wants := []string{
`{"udp":[{"settings":{"password":"p"},"type":"salamander"},` + hop + `]}`,
`{"udp":[` + hop + `]}`,
}
for i, want := range wants {
var config struct {
Outbounds []struct {
StreamSettings map[string]any `json:"streamSettings"`
} `json:"outbounds"`
}
if err := json.Unmarshal(configs[i], &config); err != nil {
t.Fatalf("decode config %d: %v", i, err)
}
got, _ := json.Marshal(config.Outbounds[0].StreamSettings["finalmask"])
if string(got) != want {
t.Fatalf("endpoint %d finalmask\n got: %s\nwant: %s", i, got, want)
}
}
}
+13 -6
View File
@@ -1,9 +1,13 @@
package maskcompat
import "strings"
import (
"maps"
"slices"
"strings"
)
// UpgradeLegacyUDPHop moves the quicParams.udpHop range xray-core 26.9.9 stopped reading
// into the client-only "udphop" UDP mask, whose intervalRemote mode is what the key did.
// UpgradeLegacyUDPHop moves the quicParams.udpHop xray-core 26.9.9 ignores into an intervalRemote
// "udphop" mask, rewriting only finalmask's own keys: a merged finalmask shares quicParams and udp.
func UpgradeLegacyUDPHop(finalmask any) bool {
fm, _ := finalmask.(map[string]any)
quicParams, _ := fm["quicParams"].(map[string]any)
@@ -11,9 +15,12 @@ func UpgradeLegacyUDPHop(finalmask any) bool {
if !found {
return false
}
delete(quicParams, "udpHop")
if len(quicParams) == 0 {
restQuic := maps.Clone(quicParams)
delete(restQuic, "udpHop")
if len(restQuic) == 0 {
delete(fm, "quicParams")
} else {
fm["quicParams"] = restQuic
}
hop, _ := legacy.(map[string]any)
@@ -26,7 +33,7 @@ func UpgradeLegacyUDPHop(finalmask any) bool {
settings["interval"] = interval
}
masks, _ := fm["udp"].([]any)
fm["udp"] = append(masks, map[string]any{"type": "udphop", "settings": settings})
fm["udp"] = append(slices.Clip(masks), map[string]any{"type": "udphop", "settings": settings})
return true
}