From 7f7b7e16a4255ac3703b2ffc984db2826ad4b77a Mon Sep 17 00:00:00 2001 From: Sanaei Date: Tue, 28 Jul 2026 13:14:06 +0200 Subject: [PATCH] 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. --- .github/workflows/release.yml | 4 +- .gitignore | 3 +- DockerInit.sh | 2 +- .../xray/forms/transport/FinalMaskForm.tsx | 139 ++++++++++-- .../src/schemas/protocols/stream/xhttp.ts | 8 +- frontend/src/test/finalmask.test.ts | 46 +++- .../src/test/stream-wire-normalize.test.ts | 6 +- go.mod | 2 +- go.sum | 4 +- internal/web/service/inbound.go | 145 +++++++++++++ .../web/service/inbound_finalmask_xmc_test.go | 204 ++++++++++++++++++ internal/web/service/xray.go | 4 + 12 files changed, 541 insertions(+), 26 deletions(-) create mode 100644 internal/web/service/inbound_finalmask_xmc_test.go 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