From 27e45f303928f273ae44e7459f2300f79872c77b Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 11 Oct 2026 16:35:16 +0200 Subject: [PATCH] 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. --- internal/sub/masque_test.go | 42 ++++++++++++++++++++++++++++++ internal/util/maskcompat/udphop.go | 19 +++++++++----- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/internal/sub/masque_test.go b/internal/sub/masque_test.go index 58572b629..85e46e3e1 100644 --- a/internal/sub/masque_test.go +++ b/internal/sub/masque_test.go @@ -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) + } + } +} diff --git a/internal/util/maskcompat/udphop.go b/internal/util/maskcompat/udphop.go index f326f2621..85512a300 100644 --- a/internal/util/maskcompat/udphop.go +++ b/internal/util/maskcompat/udphop.go @@ -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 }