feat(xray): update xray-core to v26.7.28 and adapt panel

Bump xtls/xray-core to 5ca6f4b7d4dc (v26.7.28) and move the three binary
pins (DockerInit.sh, the Linux and Windows URLs in release.yml) in lockstep
so the in-process conf.Build() validation and the child binary agree.

XMC finalmask (#6487) is the breaking change. The mask's `usernames` string
list is gone, replaced by a required `profiles` array whose entries each need
a 3-16 character [A-Za-z0-9_] username, a parseable UUID and both Mojang
texture fields; the "default to Dream when empty" fallback was removed, so an
xmc mask saved by an older panel now fails to build and takes the whole
config down with it rather than degrading one inbound.

The textures are a signed blob only Mojang's session server can issue, so a
legacy username cannot be upgraded automatically. The panel now:

- rejects an incomplete xmc mask at save time (AddInbound/UpdateInbound),
  pointing at the specific field that is missing;
- drops only the offending mask when generating the core config, for rows
  that never went through the form (upgrade, node sync, restored backup,
  direct DB edit), warning which inbound lost its obfuscation instead of
  leaving every inbound offline;
- carries legacy usernames into profile stubs in the finalmask form so the
  operator keeps their player names and sees exactly what still needs
  filling in, and edits profiles through a list editor.

No destructive DB migration: unlike the removed shadowsocks ciphers there is
no valid replacement to rewrite to, and dropping the mask from stored rows
would discard the operator's hostname and password for config they can still
repair. The generation-time strip already prevents the startup failure.

Also track the core's xmux maxConnections fallback, lowered from 6 to 3 for
anti-TSPU, in the fresh-XMUX seed so a new panel config matches what the core
would pick on its own.

TUN gained a `desc` key and random utunN naming, but the Go validator no
longer accepts TUN inbounds and the panel only renders legacy saved rows, so
nothing there needs adapting. The remaining commits are REALITY log-warning
wording, gRPC/XHTTP localAddr accuracy and a routing tweak, none of which
change the JSON config surface.

Tests cross-check the panel's profile predicate against conf.XMCProfile.Build()
so a future core release that tightens or relaxes the rules fails loudly
rather than silently emitting configs the core refuses to start on.
This commit is contained in:
Sanaei
2026-07-28 13:14:06 +02:00
parent fd17255f1d
commit 7f7b7e16a4
12 changed files with 541 additions and 26 deletions
+145
View File
@@ -8,10 +8,13 @@ import (
"errors"
"fmt"
"net"
"regexp"
"sort"
"strings"
"time"
"github.com/google/uuid"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
@@ -592,6 +595,142 @@ func validateFinalMaskRealityCombo(streamSettings string) error {
return common.NewError("Finalmask is not supported with REALITY security — it crashes Xray-core on the first connection (see XTLS/Xray-core#6453). Remove the finalmask configuration or switch security to tls/none.")
}
var xmcProfileUsernamePattern = regexp.MustCompile(`^[A-Za-z0-9_]{3,16}$`)
// xmcMaskProfilesComplete reports whether an xmc finalmask carries the signed
// Minecraft session profiles xray-core has required since v26.7.28 (#6487).
// The core replaced the old `usernames` string list with `profiles` objects
// and removed the "default to Dream when empty" fallback, so a mask still on
// the legacy shape — or one whose profiles are incomplete — now fails
// conf.XMC.Build() and takes the entire config down with it rather than
// degrading that one inbound.
//
// The texture fields are a signed blob only Mojang's session server can issue
// (resolve the UUID by username, then fetch the profile with unsigned=false),
// so the panel cannot synthesize a valid profile from a legacy username; an
// incomplete mask can only be reported or dropped.
func xmcMaskProfilesComplete(mask map[string]any) bool {
settings, ok := mask["settings"].(map[string]any)
if !ok {
return false
}
profiles, _ := settings["profiles"].([]any)
if len(profiles) == 0 {
return false
}
for _, entry := range profiles {
profile, ok := entry.(map[string]any)
if !ok {
return false
}
username, _ := profile["username"].(string)
if !xmcProfileUsernamePattern.MatchString(username) {
return false
}
id, _ := profile["uuid"].(string)
if _, err := uuid.Parse(id); err != nil {
return false
}
if value, _ := profile["texturesValue"].(string); value == "" {
return false
}
if signature, _ := profile["texturesSignature"].(string); signature == "" {
return false
}
}
return true
}
// isIncompleteXmcMask reports whether a finalmask.tcp entry is an xmc mask
// xray-core would refuse to build.
func isIncompleteXmcMask(entry any) bool {
mask, ok := entry.(map[string]any)
if !ok {
return false
}
if maskType, _ := mask["type"].(string); maskType != "xmc" {
return false
}
return !xmcMaskProfilesComplete(mask)
}
// incompleteXmcMaskCount counts the stream's xmc finalmask entries that
// xray-core would refuse to build.
func incompleteXmcMaskCount(stream map[string]any) int {
finalmask, ok := stream["finalmask"].(map[string]any)
if !ok {
return 0
}
tcp, _ := finalmask["tcp"].([]any)
count := 0
for _, entry := range tcp {
if isIncompleteXmcMask(entry) {
count++
}
}
return count
}
// stripIncompleteXmcMasks removes every xmc finalmask entry xray-core would
// refuse to build, returning how many were dropped, and clears the finalmask
// object once nothing is left in it.
//
// AddInbound and UpdateInbound reject an incomplete mask at save time, but a
// row that never went through those paths — an upgrade from a panel predating
// v26.7.28, node sync, a restored backup, a direct DB edit — would otherwise
// fail the whole config build and keep every other inbound offline too.
// Dropping only the offending mask degrades that one inbound instead, which
// the accompanying warning tells the admin to reconfigure.
func stripIncompleteXmcMasks(stream map[string]any) int {
finalmask, ok := stream["finalmask"].(map[string]any)
if !ok {
return 0
}
tcp, _ := finalmask["tcp"].([]any)
if len(tcp) == 0 {
return 0
}
kept := make([]any, 0, len(tcp))
dropped := 0
for _, entry := range tcp {
if isIncompleteXmcMask(entry) {
dropped++
continue
}
kept = append(kept, entry)
}
if dropped == 0 {
return 0
}
if len(kept) == 0 {
delete(finalmask, "tcp")
} else {
finalmask["tcp"] = kept
}
if len(finalmask) == 0 {
delete(stream, "finalmask")
}
return dropped
}
// validateFinalMaskXmcProfiles rejects an xmc finalmask without complete
// profiles at save time, so the admin gets a targeted error instead of a core
// that refuses to start (or, after GetXrayConfig heals it, an inbound quietly
// serving without the obfuscation they configured).
func validateFinalMaskXmcProfiles(streamSettings string) error {
if streamSettings == "" {
return nil
}
var stream map[string]any
if err := json.Unmarshal([]byte(streamSettings), &stream); err != nil {
return nil
}
if incompleteXmcMaskCount(stream) == 0 {
return nil
}
return common.NewError("XMC finalmask requires at least one complete Minecraft profile — each needs a username (3-16 of A-Z a-z 0-9 _), a UUID, and both texture fields from Mojang's session server (XTLS/Xray-core#6487). Complete the profiles or remove the XMC mask.")
}
// normalizeMtprotoSecret rebuilds every mtproto client's FakeTLS secret so it is
// always valid before the row is persisted, and drops the vestigial inbound-level
// secret and adTag: MTProto is multi-client, so mtg and every share link read
@@ -725,6 +864,9 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
if err := validateFinalMaskRealityCombo(inbound.StreamSettings); err != nil {
return inbound, false, err
}
if err := validateFinalMaskXmcProfiles(inbound.StreamSettings); err != nil {
return inbound, false, err
}
s.normalizeMtprotoSecret(inbound)
if err := s.normalizeMtprotoXrayPort(inbound, ""); err != nil {
return inbound, false, err
@@ -1148,6 +1290,9 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
if err := validateFinalMaskRealityCombo(inbound.StreamSettings); err != nil {
return inbound, false, err
}
if err := validateFinalMaskXmcProfiles(inbound.StreamSettings); err != nil {
return inbound, false, err
}
s.normalizeMtprotoSecret(inbound)
inbound.SubSortIndex = normalizeSubSortIndex(inbound.SubSortIndex)
@@ -0,0 +1,204 @@
package service
import (
"encoding/json"
"testing"
"github.com/xtls/xray-core/infra/conf"
)
const completeXmcProfile = `{"username":"Notch","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`
func TestValidateFinalMaskXmcProfiles(t *testing.T) {
tests := []struct {
name string
streamSettings string
wantErr bool
}{
{
name: "empty streamSettings",
streamSettings: "",
wantErr: false,
},
{
name: "no finalmask",
streamSettings: `{"network":"tcp","security":"none"}`,
wantErr: false,
},
{
name: "non-xmc mask is untouched",
streamSettings: `{"finalmask":{"tcp":[{"type":"fragment","settings":{"packets":"tlshello"}}]}}`,
wantErr: false,
},
{
name: "xmc with a complete profile",
streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"hostname":"mc.example.com","password":"pw","profiles":[` + completeXmcProfile + `]}}]}}`,
wantErr: false,
},
{
name: "legacy usernames shape without profiles",
streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"hostname":"mc.example.com","password":"pw","usernames":["Dream"]}}]}}`,
wantErr: true,
},
{
name: "xmc with an empty profiles array",
streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[]}}]}}`,
wantErr: true,
},
{
name: "profile missing the textures signature",
streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[{"username":"Notch","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":""}]}}]}}`,
wantErr: true,
},
{
name: "profile with an unparseable uuid",
streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[{"username":"Notch","uuid":"not-a-uuid","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}]}}]}}`,
wantErr: true,
},
{
name: "profile with an out-of-range username",
streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[{"username":"ab","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}]}}]}}`,
wantErr: true,
},
{
name: "one complete and one incomplete profile",
streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[` + completeXmcProfile + `,{"username":"Herobrine","uuid":"","texturesValue":"","texturesSignature":""}]}}]}}`,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateFinalMaskXmcProfiles(tt.streamSettings)
if (err != nil) != tt.wantErr {
t.Errorf("validateFinalMaskXmcProfiles(%q) error = %v, wantErr %v", tt.streamSettings, err, tt.wantErr)
}
})
}
}
// TestXmcMaskProfilesCompleteMatchesCoreValidation pins the panel's predicate
// to xray-core's own loader instead of a restatement of it: every profile the
// panel accepts must build, and every one it rejects must fail to build. A
// future core release that tightens or relaxes the rules fails here rather
// than silently producing configs the core refuses to start on.
func TestXmcMaskProfilesCompleteMatchesCoreValidation(t *testing.T) {
profiles := []struct {
name string
raw string
}{
{name: "complete", raw: completeXmcProfile},
{name: "undashed uuid", raw: `{"username":"Notch","uuid":"069a79f444e94726a5befca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`},
{name: "username at the 16 char limit", raw: `{"username":"Abcdefghijklmnop","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`},
{name: "username over the limit", raw: `{"username":"Abcdefghijklmnopq","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`},
{name: "username with a hyphen", raw: `{"username":"No-tch","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`},
{name: "empty uuid", raw: `{"username":"Notch","uuid":"","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`},
{name: "missing textures value", raw: `{"username":"Notch","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"","texturesSignature":"c2ln"}`},
}
for _, tt := range profiles {
t.Run(tt.name, func(t *testing.T) {
var coreProfile conf.XMCProfile
if err := json.Unmarshal([]byte(tt.raw), &coreProfile); err != nil {
t.Fatalf("unmarshal into conf.XMCProfile: %v", err)
}
_, coreErr := coreProfile.Build()
mask := map[string]any{"type": "xmc"}
var settings map[string]any
if err := json.Unmarshal([]byte(`{"profiles":[`+tt.raw+`]}`), &settings); err != nil {
t.Fatalf("unmarshal settings: %v", err)
}
mask["settings"] = settings
panelAccepts := xmcMaskProfilesComplete(mask)
coreAccepts := coreErr == nil
if panelAccepts != coreAccepts {
t.Errorf("xmcMaskProfilesComplete = %v, but conf.XMCProfile.Build() accepts = %v (err %v)", panelAccepts, coreAccepts, coreErr)
}
})
}
}
// TestXmcEmptyProfilesRejectedByCore covers the rule that lives on XMC rather
// than XMCProfile: v26.7.28 dropped the "default to Dream" fallback, so a mask
// with no profiles at all is now a build failure.
func TestXmcEmptyProfilesRejectedByCore(t *testing.T) {
var core conf.XMC
if err := json.Unmarshal([]byte(`{"hostname":"mc.example.com","password":"pw","profiles":[]}`), &core); err != nil {
t.Fatalf("unmarshal into conf.XMC: %v", err)
}
if _, err := core.Build(); err == nil {
t.Fatal("conf.XMC.Build() accepted an empty profiles list; the panel's strip/validate pair is no longer needed")
}
}
func TestStripIncompleteXmcMasks(t *testing.T) {
tests := []struct {
name string
stream string
wantDropped int
wantStream string
}{
{
name: "legacy usernames mask is dropped and finalmask removed",
stream: `{"network":"tcp","finalmask":{"tcp":[{"type":"xmc","settings":{"usernames":["Dream"],"password":"pw"}}]}}`,
wantDropped: 1,
wantStream: `{"network":"tcp"}`,
},
{
name: "complete mask is kept",
stream: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[` + completeXmcProfile + `]}}]}}`,
wantDropped: 0,
wantStream: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[` + completeXmcProfile + `]}}]}}`,
},
{
name: "sibling masks survive the drop",
stream: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"usernames":["Dream"]}},{"type":"fragment","settings":{"packets":"tlshello"}}]}}`,
wantDropped: 1,
wantStream: `{"finalmask":{"tcp":[{"type":"fragment","settings":{"packets":"tlshello"}}]}}`,
},
{
name: "udp masks are preserved when tcp empties out",
stream: `{"finalmask":{"tcp":[{"type":"xmc","settings":{}}],"udp":[{"type":"salamander"}]}}`,
wantDropped: 1,
wantStream: `{"finalmask":{"udp":[{"type":"salamander"}]}}`,
},
{
name: "stream without finalmask is untouched",
stream: `{"network":"tcp","security":"tls"}`,
wantDropped: 0,
wantStream: `{"network":"tcp","security":"tls"}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var stream map[string]any
if err := json.Unmarshal([]byte(tt.stream), &stream); err != nil {
t.Fatalf("unmarshal stream: %v", err)
}
dropped := stripIncompleteXmcMasks(stream)
if dropped != tt.wantDropped {
t.Errorf("stripIncompleteXmcMasks dropped = %d, want %d", dropped, tt.wantDropped)
}
var want map[string]any
if err := json.Unmarshal([]byte(tt.wantStream), &want); err != nil {
t.Fatalf("unmarshal wantStream: %v", err)
}
got, err := json.Marshal(stream)
if err != nil {
t.Fatalf("marshal stream: %v", err)
}
wantJSON, err := json.Marshal(want)
if err != nil {
t.Fatalf("marshal want: %v", err)
}
if string(got) != string(wantJSON) {
t.Errorf("stream after strip = %s, want %s", got, wantJSON)
}
})
}
}
+4
View File
@@ -281,6 +281,10 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
delete(stream, "finalmask")
}
if dropped := stripIncompleteXmcMasks(stream); dropped > 0 {
logger.Warningf("Inbound %q: dropping %d XMC finalmask mask(s) without complete Minecraft profiles — reconfigure them to restore the obfuscation (see XTLS/Xray-core#6487)", inbound.Tag, dropped)
}
// xray-core v26.6.22 (#6258) renamed the XHTTP session keys and
// kept no fallback. Lift legacy sessionPlacement/sessionKey onto the
// new names here so inbounds stored before the rename keep working