Browse Source

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.
MHSanaei 1 day ago
parent
commit
27e45f3039
2 changed files with 55 additions and 6 deletions
  1. 42 0
      internal/sub/masque_test.go
  2. 13 6
      internal/util/maskcompat/udphop.go

+ 42 - 0
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":"[email protected]","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: "[email protected]", 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
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
 }