fix(finalmask): keep a single udphop mask last in every client finalmask

xray-core reverses finalmask.udp and refuses, at dial time, a mask that
dials its own socket (udphop) anywhere but the socket end: "incorrect
index". xray run -test still says Configuration OK, so the failure only
shows as a client that never connects.

UDPHopClientMaskFix turned a global subJsonFinalMask hop and a host hop
into udphop masks; the JSON subscription then appended both, leaving two
hoppers with the first one misplaced, and a host mask merged after a
global hop pushed it off the end too. A hand-ordered template outbound
or an imported fm= could carry the same order.

maskcompat.NormalizeUDPHop keeps the last-listed udphop alone at the end
of udp, and runs wherever a client finalmask is produced: each JSON
subscription endpoint, share-link fm=, the Go and frontend link
importers and the template outbound heal in GetXrayConfig. The JSON
subscription resolves competing hop ranges most specific first: a host
hop wins over the inbound's own, which wins over the global default.
This commit is contained in:
MHSanaei
2026-10-11 18:02:12 +02:00
parent a8255944bf
commit 6321297f68
12 changed files with 281 additions and 43 deletions
+1
View File
@@ -770,6 +770,7 @@ func applyFinalMask(stream map[string]any, p url.Values) {
sanitizeFinalMaskQuicParams(parsed)
maskcompat.UpgradeLegacyXdns(parsed)
maskcompat.UpgradeLegacyUDPHop(parsed)
maskcompat.NormalizeUDPHop(parsed)
stream["finalmask"] = parsed
}
}
+16
View File
@@ -596,3 +596,19 @@ func TestParseLink_UpgradesLegacyUDPHopFinalMask(t *testing.T) {
t.Fatalf("imported finalmask\n got: %s\nwant: %s", got, want)
}
}
// A link from a panel that listed udphop before another UDP mask would leave an outbound
// whose every dial the core refuses ("incorrect index"); the import puts the hop last.
func TestParseLink_PutsUDPHopMaskLast(t *testing.T) {
fm := url.QueryEscape(`{"udp":[{"type":"udphop","settings":{"mode":"intervalRemote","remotePorts":"20000-30000"}},{"type":"salamander","settings":{"password":"s3cr3t"}}]}`)
res, err := ParseLink("hysteria2://auth@hy.example.com:443?sni=hy.example.com&fm=" + fm + "#hy")
if err != nil {
t.Fatalf("parse hysteria2 with fm: %v", err)
}
stream, _ := res.Outbound["streamSettings"].(map[string]any)
got, _ := json.Marshal(stream["finalmask"])
want := `{"udp":[{"settings":{"password":"s3cr3t"},"type":"salamander"},{"settings":{"mode":"intervalRemote","remotePorts":"20000-30000"},"type":"udphop"}]}`
if string(got) != want {
t.Fatalf("imported finalmask\n got: %s\nwant: %s", got, want)
}
}
+48 -6
View File
@@ -37,15 +37,57 @@ func UpgradeLegacyUDPHop(finalmask any) bool {
return true
}
func hasUDPHopMask(fm map[string]any) bool {
// NormalizeUDPHop keeps the last udphop mask alone at the end of udp: the core reverses the list
// and refuses ("incorrect index") a mask that dials its own socket anywhere but the socket end.
func NormalizeUDPHop(finalmask any) bool {
fm, _ := finalmask.(map[string]any)
masks, _ := fm["udp"].([]any)
for _, entry := range masks {
mask, _ := entry.(map[string]any)
if maskType, _ := mask["type"].(string); strings.EqualFold(maskType, "udphop") {
return true
last, hops := -1, 0
for i, mask := range masks {
if isUDPHopMask(mask) {
last, hops = i, hops+1
}
}
return false
if last < 0 || (hops == 1 && last == len(masks)-1) {
return false
}
kept := make([]any, 0, len(masks)-hops+1)
for _, mask := range masks {
if !isUDPHopMask(mask) {
kept = append(kept, mask)
}
}
fm["udp"] = append(kept, masks[last])
return true
}
// WithoutUDPHop returns finalmask minus its udphop masks, for a default that yields to a hop
// range already set; finalmask itself is left untouched.
func WithoutUDPHop(finalmask map[string]any) map[string]any {
masks, _ := finalmask["udp"].([]any)
if !slices.ContainsFunc(masks, isUDPHopMask) {
return finalmask
}
out := maps.Clone(finalmask)
out["udp"] = slices.DeleteFunc(slices.Clone(masks), isUDPHopMask)
return out
}
// HasUDPHopMask reports whether finalmask already dials through a udphop mask.
func HasUDPHopMask(finalmask any) bool {
fm, _ := finalmask.(map[string]any)
return hasUDPHopMask(fm)
}
func hasUDPHopMask(fm map[string]any) bool {
masks, _ := fm["udp"].([]any)
return slices.ContainsFunc(masks, isUDPHopMask)
}
func isUDPHopMask(entry any) bool {
mask, _ := entry.(map[string]any)
maskType, _ := mask["type"].(string)
return strings.EqualFold(maskType, "udphop")
}
func isEmptyHopValue(value any) bool {