Files
3x-ui/internal/util/maskcompat/udphop.go
T
MHSanaei 6321297f68 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.
2026-10-11 18:02:12 +02:00

104 lines
2.8 KiB
Go

package maskcompat
import (
"maps"
"slices"
"strings"
)
// 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)
legacy, found := quicParams["udpHop"]
if !found {
return false
}
restQuic := maps.Clone(quicParams)
delete(restQuic, "udpHop")
if len(restQuic) == 0 {
delete(fm, "quicParams")
} else {
fm["quicParams"] = restQuic
}
hop, _ := legacy.(map[string]any)
ports := hop["ports"]
if isEmptyHopValue(ports) || hasUDPHopMask(fm) {
return true
}
settings := map[string]any{"mode": "intervalRemote", "remotePorts": ports}
if interval := hop["interval"]; !isEmptyHopValue(interval) {
settings["interval"] = interval
}
masks, _ := fm["udp"].([]any)
fm["udp"] = append(slices.Clip(masks), map[string]any{"type": "udphop", "settings": settings})
return true
}
// 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)
last, hops := -1, 0
for i, mask := range masks {
if isUDPHopMask(mask) {
last, hops = i, hops+1
}
}
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 {
switch v := value.(type) {
case nil:
return true
case string:
return strings.TrimSpace(v) == ""
case float64:
return v == 0
}
return false
}