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 }