diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 604697eb5..8aa5e4101 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -124,7 +124,7 @@ jobs: cd x-ui/bin # Download dependencies - Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.7.11/" + Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.7.28/" if [ "${{ matrix.platform }}" == "amd64" ]; then fetch ${Xray_URL}Xray-linux-64.zip unzip Xray-linux-64.zip @@ -282,7 +282,7 @@ jobs: cd x-ui\bin # Download Xray for Windows - $Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.7.11/" + $Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.7.28/" Invoke-WebRequest @retry -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip" Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath . Remove-Item "Xray-windows-64.zip" diff --git a/.gitignore b/.gitignore index 69e2eb2f3..70cbf0315 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,8 @@ .idea/ .vscode/ .cursor/ -.claude/* +.specify/ +.claude/ .cache/ .sync* diff --git a/DockerInit.sh b/DockerInit.sh index 172180ab9..9c23fb55c 100755 --- a/DockerInit.sh +++ b/DockerInit.sh @@ -32,7 +32,7 @@ if [ -z "$MTG_MULTI_VER" ]; then fi mkdir -p build/bin cd build/bin -curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.7.11/Xray-linux-${ARCH}.zip" +curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.7.28/Xray-linux-${ARCH}.zip" unzip "Xray-linux-${ARCH}.zip" rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat mv xray "xray-linux-${FNAME}" diff --git a/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx b/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx index 7406e59d6..7c359110a 100644 --- a/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx +++ b/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx @@ -82,12 +82,43 @@ function defaultTcpMaskSettings(type: string): Record { case 'header-custom': return { clients: [], servers: [] }; case 'xmc': - return { hostname: '', usernames: [], password: RandomUtil.randomLowerAndNum(16) }; + return { hostname: '', profiles: [defaultXmcProfile()], password: RandomUtil.randomLowerAndNum(16) }; default: return {}; } } +function defaultXmcProfile(): Record { + return { username: '', uuid: '', texturesValue: '', texturesSignature: '' }; +} + +// xray-core #6487 replaced the xmc mask's `usernames` string list with +// `profiles` objects carrying a Mojang-signed session profile, and dropped the +// "default to Dream" fallback so at least one complete profile is now +// mandatory. The signature can only come from Mojang's session server, so a +// legacy username cannot be upgraded automatically — carry it into a profile +// stub instead, which keeps the operator's player names visible and leaves the +// per-field validators pointing at exactly what still has to be filled in. +export function migrateXmcSettings(settings: Record): { next: Record; changed: boolean } { + const out: Record = { ...settings }; + let changed = false; + if (!Array.isArray(out.profiles) && Array.isArray(out.usernames)) { + out.profiles = out.usernames + .filter((name): name is string => typeof name === 'string' && name.trim() !== '') + .map((name) => ({ ...defaultXmcProfile(), username: name })); + changed = true; + } + if ('usernames' in out) { + delete out.usernames; + changed = true; + } + if (!Array.isArray(out.profiles)) { + out.profiles = []; + changed = true; + } + return { next: out, changed }; +} + // xray-core #6334 replaced a fragment mask's single `length`/`delay` ranges // with `lengths`/`delays` arrays (the singular keys remain in core only as a // fallback). Lift any legacy singular value into a one-element array so the @@ -171,8 +202,8 @@ function defaultUdpHop(): Record { export default function FinalMaskForm({ name, network, protocol, form, showAll = false }: FinalMaskFormProps) { const base = asPath(name); - // Migrate legacy single-range fragment masks to the per-segment arrays once - // on mount so configs saved before #6334 render in the list UI. + // Migrate legacy TCP mask shapes once on mount so configs saved before + // #6334 (fragment ranges) and #6487 (xmc profiles) render in the list UI. const migratedRef = useRef(false); useEffect(() => { if (migratedRef.current) return; @@ -183,8 +214,12 @@ export default function FinalMaskForm({ name, network, protocol, form, showAll = const next = tcp.map((mask) => { if (!mask || typeof mask !== 'object') return mask; const m = mask as Record; - if (m.type !== 'fragment' || !m.settings || typeof m.settings !== 'object') return mask; - const { next: migrated, changed } = migrateFragmentSettings(m.settings as Record); + if (m.type !== 'fragment' && m.type !== 'xmc') return mask; + if (!m.settings || typeof m.settings !== 'object') return mask; + const settings = m.settings as Record; + const { next: migrated, changed } = m.type === 'fragment' + ? migrateFragmentSettings(settings) + : migrateXmcSettings(settings); if (!changed) return mask; anyChanged = true; return { ...m, settings: migrated }; @@ -380,13 +415,7 @@ function TcpMaskItem({ - - + + + + + + + + + + + + ))} + + )} + + ); +} + function HeaderCustomGroups({ tcpFieldName, form, absoluteSettingsPath, }: { diff --git a/frontend/src/schemas/protocols/stream/xhttp.ts b/frontend/src/schemas/protocols/stream/xhttp.ts index 7d61e4544..58751a5fb 100644 --- a/frontend/src/schemas/protocols/stream/xhttp.ts +++ b/frontend/src/schemas/protocols/stream/xhttp.ts @@ -31,12 +31,14 @@ export const XHttpXmuxSchema = z.object({ export type XHttpXmux = z.infer; // Seed for freshly enabling XMUX on a config that had no xmux block: -// mirrors xray-core v26.6.27's own anti-RKN maxConnections=6 fallback -// rather than the concurrency strategy. +// mirrors xray-core's own maxConnections fallback rather than the +// concurrency strategy. v26.7.28 lowered that fallback from 6 to 3 for +// anti-TSPU, so track it here to keep a fresh panel config matching what +// the core would have picked on its own. export const XMUX_FRESH_DEFAULTS: XHttpXmux = { ...XHttpXmuxSchema.parse({}), maxConcurrency: '', - maxConnections: 6, + maxConnections: 3, }; // Predefined sessionIDTable names xray-core accepts as a shorthand for a diff --git a/frontend/src/test/finalmask.test.ts b/frontend/src/test/finalmask.test.ts index 152ae5418..cc37ca1b0 100644 --- a/frontend/src/test/finalmask.test.ts +++ b/frontend/src/test/finalmask.test.ts @@ -1,7 +1,7 @@ /// import { describe, expect, it } from 'vitest'; -import { parseGeckoPacketSize } from '@/lib/xray/forms/transport/FinalMaskForm'; +import { migrateXmcSettings, parseGeckoPacketSize } from '@/lib/xray/forms/transport/FinalMaskForm'; import { FinalMaskStreamSettingsSchema } from '@/schemas/protocols/stream'; const fixtures = import.meta.glob( @@ -26,6 +26,50 @@ describe('FinalMaskStreamSettingsSchema fixtures', () => { } }); +describe('migrateXmcSettings', () => { + it('carries legacy usernames into profile stubs and drops the dead key', () => { + const { next, changed } = migrateXmcSettings({ + hostname: 'mc.example.com', + usernames: ['Dream', 'Notch'], + password: 'pw', + }); + + expect(changed).toBe(true); + expect(next.usernames).toBeUndefined(); + expect(next.hostname).toBe('mc.example.com'); + expect(next.password).toBe('pw'); + expect(next.profiles).toEqual([ + { username: 'Dream', uuid: '', texturesValue: '', texturesSignature: '' }, + { username: 'Notch', uuid: '', texturesValue: '', texturesSignature: '' }, + ]); + }); + + it('gives a mask with neither key an empty profiles list', () => { + const { next, changed } = migrateXmcSettings({ hostname: '', password: 'pw' }); + + expect(changed).toBe(true); + expect(next.profiles).toEqual([]); + }); + + it('leaves an already migrated mask untouched', () => { + const profiles = [ + { username: 'Notch', uuid: '069a79f4-44e9-4726-a5be-fca90e38aaf5', texturesValue: 'dmFsdWU=', texturesSignature: 'c2ln' }, + ]; + const { next, changed } = migrateXmcSettings({ hostname: '', password: 'pw', profiles }); + + expect(changed).toBe(false); + expect(next.profiles).toEqual(profiles); + }); + + it('discards blank legacy usernames rather than seeding unfixable stubs', () => { + const { next } = migrateXmcSettings({ usernames: ['Dream', '', ' '], password: 'pw' }); + + expect(next.profiles).toEqual([ + { username: 'Dream', uuid: '', texturesValue: '', texturesSignature: '' }, + ]); + }); +}); + describe('parseGeckoPacketSize', () => { it('accepts positive ordered packet size ranges', () => { expect(parseGeckoPacketSize('512-1200')).toEqual({ min: 512, max: 1200 }); diff --git a/frontend/src/test/stream-wire-normalize.test.ts b/frontend/src/test/stream-wire-normalize.test.ts index 9c70f1bea..e7fc1e60e 100644 --- a/frontend/src/test/stream-wire-normalize.test.ts +++ b/frontend/src/test/stream-wire-normalize.test.ts @@ -158,8 +158,8 @@ describe('normalizeXhttpForWire stream-one', () => { expect(XHttpXmuxSchema.parse({}).maxConcurrency).toBe('16-32'); }); - it('XMUX_FRESH_DEFAULTS seeds the anti-RKN maxConnections=6 without a competing maxConcurrency', () => { - expect(XMUX_FRESH_DEFAULTS.maxConnections).toBe(6); + it('XMUX_FRESH_DEFAULTS seeds the core maxConnections fallback without a competing maxConcurrency', () => { + expect(XMUX_FRESH_DEFAULTS.maxConnections).toBe(3); expect(XMUX_FRESH_DEFAULTS.maxConcurrency).toBe(''); const out = normalizeXhttpForWire({ @@ -170,7 +170,7 @@ describe('normalizeXhttpForWire stream-one', () => { }, 'outbound'); const xmux = out.xmux as Record; - expect(xmux.maxConnections).toBe(6); + expect(xmux.maxConnections).toBe(3); expect(xmux.maxConcurrency).toBe(''); }); }); diff --git a/go.mod b/go.mod index 4c6e7a9cf..68fac3747 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/valyala/fasthttp v1.72.0 github.com/xlzd/gotp v0.1.0 - github.com/xtls/xray-core v1.260327.1-0.20260711155151-50231eaff98c + github.com/xtls/xray-core v1.260327.1-0.20260728075948-5ca6f4b7d4dc go.uber.org/atomic v1.11.0 golang.org/x/crypto v0.54.0 golang.org/x/sys v0.47.0 diff --git a/go.sum b/go.sum index cac81ef15..d999412d9 100644 --- a/go.sum +++ b/go.sum @@ -218,8 +218,8 @@ github.com/xlzd/gotp v0.1.0 h1:37blvlKCh38s+fkem+fFh7sMnceltoIEBYTVXyoa5Po= github.com/xlzd/gotp v0.1.0/go.mod h1:ndLJ3JKzi3xLmUProq4LLxCuECL93dG9WASNLpHz8qg= github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f h1:iy2JRioxmUpoJ3SzbFPyTxHZMbR/rSHP7dOOgYaq1O8= github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f/go.mod h1:DsJblcWDGt76+FVqBVwbwRhxyyNJsGV48gJLch0OOWI= -github.com/xtls/xray-core v1.260327.1-0.20260711155151-50231eaff98c h1:SbB1ez0bqZllbzaVj0PC+Vje3dRA8m/7jW1ussjDSgM= -github.com/xtls/xray-core v1.260327.1-0.20260711155151-50231eaff98c/go.mod h1:Jts8yHqPCpvsdL5CW5xMd8H9d2fkg1cILeBNqEwRXNw= +github.com/xtls/xray-core v1.260327.1-0.20260728075948-5ca6f4b7d4dc h1:fkOkmgHWbF2Q8MdV9VxrsyxRz4OndcrUXUkh1ANBTg0= +github.com/xtls/xray-core v1.260327.1-0.20260728075948-5ca6f4b7d4dc/go.mod h1:wukQoBGnQ6GaLTGuKwv8rCTgf80QxPj+6iznDZHQEWo= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index 902490b8b..82b345ce4 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -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) diff --git a/internal/web/service/inbound_finalmask_xmc_test.go b/internal/web/service/inbound_finalmask_xmc_test.go new file mode 100644 index 000000000..343f98c7d --- /dev/null +++ b/internal/web/service/inbound_finalmask_xmc_test.go @@ -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) + } + }) + } +} diff --git a/internal/web/service/xray.go b/internal/web/service/xray.go index f5108617e..9d8330459 100644 --- a/internal/web/service/xray.go +++ b/internal/web/service/xray.go @@ -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